diff --git a/bin/Rector/SkipUnconvertibleTestMethodRector.php b/bin/Rector/SkipUnconvertibleTestMethodRector.php index fbc79b7c..58255de2 100644 --- a/bin/Rector/SkipUnconvertibleTestMethodRector.php +++ b/bin/Rector/SkipUnconvertibleTestMethodRector.php @@ -7,6 +7,7 @@ use PhpParser\Node; use PhpParser\Node\Arg; use PhpParser\Node\Expr\MethodCall; +use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Identifier; use PhpParser\Node\Name; @@ -26,8 +27,12 @@ * * A test method is skipped when: * - its class lives in a runtime-bound namespace ({@see self::RUNTIME_NAMESPACES}) — the Facade - * tests need an active container; + * tests need an active container — or is an Acceptance test that drives an external process by a + * path absent from the mirror; * - it carries a composite data source ({@see self::COMPOSITE_DATA}) with no PHPUnit equivalent; + * - it declares an `Expect::exception()` expectation with a modifier that has no PHPUnit form + * (the substring `withMessageContaining`, `fromMethod`, …) — the chain stays as a `Testo\Expect::` + * call the runtime cannot satisfy under PHPUnit; * - it is individually listed as unconvertible ({@see self::SKIP_METHODS}) — e.g. a Tokenizer test * whose stub reprint fully-qualifies an unqualified external call. * @@ -38,11 +43,15 @@ * skipped: PHPUnit — not the engine — discovers and runs them, so a mutation cannot break discovery * to fake a pass, and these tests are exactly what gives the pipeline its mutation coverage. * - * Skipping rewrites the body to a single `$this->markTestSkipped(...)`, drops the parameters and - * removes any data-provider attributes — otherwise PHPUnit would invoke the method with the wrong - * argument count before the skip runs. Whole-file fatals (a custom constructor, a name colliding - * with a `final` TestCase method) are handled earlier by bin/build-phpunit.php, which cannot be - * fixed per method. + * Skipping PREPENDS a `$this->markTestSkipped(...)` and keeps the original body and attributes, so + * the unconvertible test stays fully visible and simply reports as skipped instead of being blanked + * out — the skip throws before the kept body runs. Parameters are dropped only when no data source + * will feed them (so PHPUnit can still call the method: a Testo composite it ignores would otherwise + * leave required parameters unfed and error before the skip); a method whose data source PHPUnit + * honors keeps its parameters so the dataset arity still matches. Lifecycle hooks are left untouched + * — they run their real body around a skipped test, which is harmless. Whole-file fatals (a custom + * constructor, a name colliding with a `final` TestCase method) are handled earlier by + * bin/build-phpunit.php, which cannot be fixed per method. */ final class SkipUnconvertibleTestMethodRector extends AbstractRector { @@ -73,18 +82,6 @@ final class SkipUnconvertibleTestMethodRector extends AbstractRector 'Testo\\Data\\DataUnion', ]; - /** Argument-feeding attributes to strip from a skipped method (else PHPUnit miscounts args). */ - private const DATA_ATTRIBUTES = [ - 'PHPUnit\\Framework\\Attributes\\DataProvider', - 'PHPUnit\\Framework\\Attributes\\TestWith', - 'PHPUnit\\Framework\\Attributes\\TestWithJson', - 'Testo\\Data\\DataProvider', - 'Testo\\Data\\DataSet', - 'Testo\\Data\\DataCross', - 'Testo\\Data\\DataZip', - 'Testo\\Data\\DataUnion', - ]; - public function getRuleDefinition(): RuleDefinition { return new RuleDefinition( @@ -93,17 +90,19 @@ public function getRuleDefinition(): RuleDefinition new CodeSample( <<<'PHP' #[\PHPUnit\Framework\Attributes\Test] - public function risky(): void + public function rejects(): never { - $result = \Testo\Testing\Helper\TestRunner::runTest([Common::class, 'risky']); - $this->assertSame($result->status, Status::Risky); + \Testo\Expect::exception(X::class)->withMessageContaining('x'); + $this->act(); } PHP, <<<'PHP' #[\PHPUnit\Framework\Attributes\Test] - public function risky(): void + public function rejects(): never { - $this->markTestSkipped('risky() exercises the Testo runtime (Testo\Testing\) and has no PHPUnit equivalent'); + $this->markTestSkipped('rejects() calls Testo\Expect with no PHPUnit form'); + \Testo\Expect::exception(X::class)->withMessageContaining('x'); + $this->act(); } PHP, ), @@ -143,39 +142,9 @@ public function refactor(Node $node): ?Node $changed = true; } - // When the whole case is skipped, its lifecycle hooks still run before/after each (skipped) - // test. Empty them: their setup is meaningless once every test is skipped, and it may - // reference Testo runtime helpers or stub files absent from the mirror (e.g. a former - // constructor turned into a #[Before] hook that `require`s a stub). - if ($classReason !== null) { - foreach ($node->getMethods() as $method) { - // Leave a hook already turned into a skipped test (it carried #[Test]) — emptying it - // would drop the markTestSkipped and PHPUnit would report it risky, not skipped. - if ($this->isLifecycleHook($method) && !$this->isAlreadySkipped($method) && ($method->stmts ?? []) !== []) { - $method->stmts = []; - $changed = true; - } - } - } - return $changed ? $node : null; } - /** A lifecycle hook by reserved name or by PHPUnit lifecycle attribute. */ - private function isLifecycleHook(ClassMethod $method): bool - { - if (\in_array(\strtolower((string) $this->getName($method)), ['setup', 'teardown', 'setupbeforeclass', 'teardownafterclass'], true)) { - return true; - } - - return $this->hasAttribute($method, [ - 'PHPUnit\\Framework\\Attributes\\Before', - 'PHPUnit\\Framework\\Attributes\\After', - 'PHPUnit\\Framework\\Attributes\\BeforeClass', - 'PHPUnit\\Framework\\Attributes\\AfterClass', - ]); - } - private function namespaceReason(?string $fqcn): ?string { if ($fqcn === null) { @@ -213,52 +182,101 @@ private function methodReason(ClassMethod $method, ?string $fqcn): ?string return 'uses a composite data source (Testo\\Data\\DataCross/DataZip/DataUnion) with no PHPUnit equivalent'; } + // An `Expect::exception()` chain carrying a modifier that ExpectExceptionToPhpUnitRector cannot + // translate (only withMessage/withCode/withMessagePattern are mapped — the substring + // withMessageContaining, fromMethod, withPrevious, … are not) is left as a `Testo\Expect::` + // call, which needs the Testo runtime state absent under PHPUnit and would fatal with + // StateNotFound. Skip such a method. (A chain with only mapped modifiers converts fine and is + // left to run — detecting it by modifier name, not by "leftover Expect", is order-independent: + // the skip rule may run before or after that conversion.) + if ($this->hasUnconvertibleExpect($method)) { + return 'declares an Expect::exception() expectation with a modifier that has no PHPUnit form (e.g. withMessageContaining)'; + } + $shortClass = $fqcn === null ? '' : \substr($fqcn, (int) \strrpos($fqcn, '\\') + 1); return self::SKIP_METHODS[$shortClass . '::' . $this->getName($method)] ?? null; } + /** Modifiers ExpectExceptionToPhpUnitRector can translate; any other on the chain aborts it. */ + private const MAPPED_EXPECT_MODIFIERS = ['withMessage', 'withCode', 'withMessagePattern']; + + /** + * Whether the body holds a `Testo\Expect::exception(...)` chain with at least one modifier that has + * no PHPUnit counterpart — the case ExpectExceptionToPhpUnitRector leaves unconverted. Each + * modifier is a `MethodCall` whose `->var` chain bottoms out at the `exception()` head. + */ + private function hasUnconvertibleExpect(ClassMethod $method): bool + { + foreach ((new \PhpParser\NodeFinder())->findInstanceOf($method->stmts ?? [], MethodCall::class) as $call) { + $head = $call->var; + while ($head instanceof MethodCall) { + $head = $head->var; + } + if (!$head instanceof StaticCall || !$this->isName($head->class, 'Testo\\Expect') || !$this->isName($head->name, 'exception')) { + continue; + } + + $modifier = $this->getName($call->name); + if ($modifier === null || !\in_array($modifier, self::MAPPED_EXPECT_MODIFIERS, true)) { + return true; + } + } + + return false; + } + private function skip(ClassMethod $method, string $reason): void { $name = $this->getName($method) ?? 'test'; - $method->params = []; - $method->stmts = [ - new Expression(new MethodCall( - new Variable('this'), - new Identifier('markTestSkipped'), - [new Arg(new String_("{$name}() {$reason}"))], - )), - ]; - - // Drop data-feeding attributes (and any group left empty), keep the rest (#[Test], #[Group]). - $groups = []; - foreach ($method->attrGroups as $group) { - $group->attrs = \array_filter( - $group->attrs, - fn($attr): bool => !$this->isAnyName($attr->name, self::DATA_ATTRIBUTES), - ); - - if ($group->attrs !== []) { - $group->attrs = \array_values($group->attrs); - $groups[] = $group; - } + $skip = new Expression(new MethodCall( + new Variable('this'), + new Identifier('markTestSkipped'), + [new Arg(new String_("{$name}() {$reason}"))], + )); + + // Prepend the skip, keeping the original body and attributes; the skip throws before the kept + // body would run. Parameters are kept when a PHPUnit data source will feed them (so the + // dataset arity still matches), and dropped otherwise so PHPUnit can still call the method — a + // Testo composite source it ignores, or no source at all, would leave required parameters + // unfed and error before the skip. + $method->stmts = [$skip, ...($method->stmts ?? [])]; + if (!$this->hasHonoredDataSource($method)) { + $method->params = []; } + } - $method->attrGroups = $groups; + /** + * Whether a data source that PHPUnit will feed to the method's parameters is present — either the + * PHPUnit attribute directly, or the Testo attribute the conversion turns into one (this rule may + * run before or after that conversion). A Testo *composite* source (DataCross/Zip/Union) is NOT + * honored — PHPUnit ignores it, so its method's parameters must still be dropped. + */ + private function hasHonoredDataSource(ClassMethod $method): bool + { + return $this->hasAttribute($method, [ + 'PHPUnit\\Framework\\Attributes\\DataProvider', + 'PHPUnit\\Framework\\Attributes\\DataProviderExternal', + 'PHPUnit\\Framework\\Attributes\\TestWith', + 'PHPUnit\\Framework\\Attributes\\TestWithJson', + 'Testo\\Data\\DataProvider', + 'Testo\\Data\\DataSet', + ]); } private function isAlreadySkipped(ClassMethod $method): bool { - if (\count($method->stmts ?? []) !== 1) { + $first = ($method->stmts ?? [])[0] ?? null; + if (!$first instanceof Expression) { return false; } - $stmt = $method->stmts[0]; + # `$this->markTestSkipped()` on a test method, `self::markTestSkipped()` on a (possibly static) hook. + $call = $first->expr; - return $stmt instanceof Expression - && $stmt->expr instanceof MethodCall - && $this->isName($stmt->expr->name, 'markTestSkipped'); + return ($call instanceof MethodCall || $call instanceof StaticCall) + && $this->isName($call->name, 'markTestSkipped'); } /** diff --git a/bin/build-phpunit.php b/bin/build-phpunit.php index 408fe450..2bba37e1 100644 --- a/bin/build-phpunit.php +++ b/bin/build-phpunit.php @@ -39,47 +39,57 @@ foreach ($roots as $srcRoot) { $srcRoot = \str_replace('\\', '/', $srcRoot); + // The mirror base this whole root maps to: $dest for the core `tests/` root (its layout already + // mirrors `Tests\PhpUnit\`), or the base derived from a sample test file's namespace for a + // plugin/bridge root (e.g. bridge/vcr/tests -> $dest/Bridge/VCR). Support data is mirrored + // relative to it so a fixture read by `__DIR__/../fixtures` still resolves. + $rootBase = mirrorBaseDir($srcRoot, $coreTestsRoot, $dest); + /** @var \SplFileInfo $file */ foreach (new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($srcRoot, \FilesystemIterator::SKIP_DOTS), ) as $file) { - if (!$file->isFile() || $file->getExtension() !== 'php') { + if (!$file->isFile()) { continue; } $path = \str_replace('\\', '/', $file->getPathname()); + // Never re-scan our own output. + if (\str_starts_with($path, $dest . '/')) { + continue; + } // Skip Self-tests: framework fixtures driven by meta-tests, not standalone unit tests. if (\str_contains($path, '/Self/')) { ++$ignored; continue; } - // Never re-scan our own output. - if (\str_starts_with($path, $dest . '/')) { - continue; - } - $code = \file_get_contents($path); - - // Support data (Stub/ or Fixture/ directory) in the core `tests/` root: the tests that use it - // tokenize it — and often `require` it — by RELATIVE PATH, so it must be mirrored verbatim at - // the same relative location under $dest, with its original filename. The namespace-based - // placement below would misplace it: its namespace may be irregular (braced, repeated, - // sub-namespaced or global) or its filename may differ from its sole class, and soleTypeName() - // would rename it out from under the `require`/read. Copied as-is; the rename Rector pass still - // relocates any `Tests\` namespace it carries. Restricted to the core `tests/` root, whose - // layout already mirrors the `Tests\PhpUnit\` PSR-4 path (so autoloading a well-behaved stub - // still resolves); plugin/bridge roots, where source layout and namespace diverge, keep the - // namespace-based placement below. - if ($srcRoot === $coreTestsRoot && \preg_match('#/(?:Stub|Fixture)s?/#', $path) === 1) { + // Support data (a Stub/Fixture/fixtures directory): the tests that use it tokenize it — and + // often `require` it — by RELATIVE PATH, so it must be mirrored verbatim next to the relocated + // tests, under its original filename. This covers every root for NON-PHP support files (a + // fixture, a VCR cassette, a `*.php.inc`, and a `.gitkeep` keeping an intentionally EMPTY stub + // dir — e.g. an empty suite location — alive), plus PHP stubs in the core `tests/` root, which + // are read/`require`d by path so must keep their name (the namespace-based placement below + // would rename them via soleTypeName()). A PHP stub CLASS in a plugin/bridge root instead + // falls through to the namespace-based placement, which its PSR-4 autoload relies on. + $isSupport = \preg_match('#/(?:stub|fixture)s?/#i', $path) === 1; + if ($isSupport && ($file->getExtension() !== 'php' || $srcRoot === $coreTestsRoot)) { $relative = \ltrim(\substr($path, \strlen($srcRoot)), '/'); - $targetFile = $dest . '/' . $relative; + $targetFile = $rootBase . '/' . $relative; @\mkdir(\dirname($targetFile), 0777, true); - \file_put_contents($targetFile, $code); + \copy($path, $targetFile); ++$copied; continue; } + // Beyond support data, only namespaced PHP test/helper files are placed. + if ($file->getExtension() !== 'php') { + continue; + } + + $code = \file_get_contents($path); + $namespace = extractNamespace($code); // Only namespaced PSR-4 classes are placeable; bare helper files (functions.php) are loaded @@ -116,6 +126,50 @@ echo "Copied: {$copied}, ignored (Self/helpers): {$ignored}\n"; echo "Next: rector + composer dump-autoload (run by the composer script).\n"; +/** + * The mirror base directory a whole source root maps to. The core `tests/` root maps straight to + * $dest; a plugin/bridge root's base is read off a sample namespaced test file — its `Tests\…` + * namespace maps to a `$dest/…` path, and stripping the file's own subdirectory within the root + * leaves the base the root maps to (e.g. bridge/vcr/tests -> $dest/Bridge/VCR). Falls back to $dest + * when the root holds no namespaced test file. + */ +function mirrorBaseDir(string $srcRoot, string $coreTestsRoot, string $dest): string +{ + if ($srcRoot === $coreTestsRoot) { + return $dest; + } + + /** @var \SplFileInfo $file */ + foreach (new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($srcRoot, \FilesystemIterator::SKIP_DOTS), + ) as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $namespace = extractNamespace((string) \file_get_contents($file->getPathname())); + if ($namespace === null) { + continue; + } + + $nsPath = \trim(\substr($namespace, \strlen('Tests')), '\\'); + $nsPath = $nsPath === '' ? '' : \str_replace('\\', '/', $nsPath); + + $filePath = \str_replace('\\', '/', $file->getPathname()); + $fileRelDir = \trim(\substr(\dirname($filePath), \strlen($srcRoot)), '/'); + + // The namespace path ends with the file's own subdirectory when the layout is regular; strip + // it to leave what the root itself maps to. + if ($fileRelDir !== '' && \str_ends_with($nsPath, $fileRelDir)) { + $nsPath = \rtrim(\substr($nsPath, 0, -\strlen($fileRelDir)), '/'); + } + + return $nsPath === '' ? $dest : $dest . '/' . $nsPath; + } + + return $dest; +} + /** Extract the file's namespace (expected to start with `Tests`), or null when there is none. */ function extractNamespace(string $code): ?string { diff --git a/bridge/rector/FEATURE_PARITY.md b/bridge/rector/FEATURE_PARITY.md index c44c1726..fbe94a26 100644 --- a/bridge/rector/FEATURE_PARITY.md +++ b/bridge/rector/FEATURE_PARITY.md @@ -15,7 +15,7 @@ Conversion coverage across the three directions supported by `testo/bridge-recto |---|:---:|:---:|:---:| | **Basic assertions** (same/equals/true/false/null/count/contains/instanceOf/fail) | ✅ *`AssertCallToPhpUnitRector`; only inside a class — `$this->assert*` in instance scope, `self::assert*` where `$this` is unavailable (static helper/data provider); a call in a free function or at namespace level is left untouched (no valid `$this`/`self::` target)* | ✅ *`AssertCallToTestoRector`; restores arg order; also inside a class only (a test method / static data provider), a call elsewhere is left untouched* | 🟡 *(`expect()->toX()`, 8 matchers, single only)* | | **actual/expected argument swap** | ✅ | ✅ | ✅ | -| **Fluent / typed chains** (`Assert::string()->…`, Pest `->not->`, `toBeGreaterThan`) | 🟡 *`TypedAssertChainRector` decomposes into separate `assert*` lines (incl. 1→N `hasKeys`, `between`, `isList`→`assertIsList`); a non-variable subject is hoisted into a `$value` local so it is evaluated once; only inside a class (the emitted `$this->assert*` needs a method scope); JSON path/structure, `every`, custom matchers are left untouched + TODO* | ⛔ *reverse means **coalescing** independent statements into one chain — impractical* | 🧩 *function host now exists; `ExpectToAssertRector` still leaves negated `->not->` and chained `->toX()->toY()` expectations untouched — mappable next* | +| **Fluent / typed chains** (`Assert::string()->…`, Pest `->not->`, `toBeGreaterThan`) | 🟡 *`TypedAssertChainRector` decomposes into separate `assert*` lines (incl. 1→N `hasKeys`, `between`, `isList`→`assertIsList`, `sameElementsAs`→`assertEqualsCanonicalizing`); a non-variable subject is hoisted into a `$value` local so it is evaluated once; only inside a class (the emitted `$this->assert*` needs a method scope); JSON path/structure, `every`, custom matchers are left untouched + TODO* | 🟡 *`TypedAssertCallToTestoRector` converts the assertions whose faithful Testo form is a typed head + matcher: comparisons (`assertGreaterThan`→`Assert::numeric()->greaterThan()`, …), array keys (`assertArrayHasKey`→`Assert::array()->hasKeys()`), `assertEqualsCanonicalizing`→`sameElementsAs`, and array-subject `assertEmpty`/`assertNotEmpty`→`blank`/`notBlank`. Each is a 1:1 statement rewrite; `MergeAssertChainRector` then folds adjacent same-head chains. General coalescing of arbitrary independent `assert*` lines remains impractical* | 🧩 *function host now exists; `ExpectToAssertRector` still leaves negated `->not->` and chained `->toX()->toY()` expectations untouched — mappable next* | | **Exception expectation (bare)** | ✅ *bare `\Testo\Expect::exception($c)` → `$this->expectException($c)` (`ExpectExceptionToPhpUnitRector`); the attribute form `#[\Testo\Assert\ExpectException($c)]` → prepended `$this->expectException($c)` (`ExpectExceptionAttributeToPhpUnitRector`)* | 🟡 | ✅ *`TestCallToFunctionRector` folds `->throws(X::class)` into a prepended `\Testo\Expect::exception(X)` + `never` return type* | | **Exception message/code (fluent)** `withMessage/withCode` ↔ `expectExceptionMessage/Code` | ✅ *`ExpectExceptionToPhpUnitRector` expands one chain into several statements (`withMessage`→`expectExceptionMessage`, `withCode`→`expectExceptionCode`, regex `withMessagePattern`→`expectExceptionMessageMatches`); substring `withMessageContaining` aborts the chain (no faithful PCRE target)* | ✅ *`ExpectExceptionToTestoRector` folds an uninterrupted run of sibling `expectExceptionMessage/Code` after `expectException` into the `->withMessage()/->withCode()` chain (StmtsAware); a non-foldable call ends the run* | 🟡 *`->throws(X, 'msg')`'s second arg folds to `->withMessage('msg')`; Pest has no exception-code modifier to map* | | **Exception message by regex** (`expectExceptionMessageMatches`) | ➖ | ⛔ *Testo's `withMessageContaining` is substring, not regex* | ➖ | @@ -30,7 +30,7 @@ Conversion coverage across the three directions supported by `testo/bridge-recto | **ExpectNoAssertions** (`#[\Testo\Assert\ExpectNoAssertions]` ↔ `#[\PHPUnit\Framework\Attributes\DoesNotPerformAssertions]`) | ✅ *`ExpectNoAssertionsToPhpUnitRector` (attribute rename; both sides method/function-level only — no fan-out)* | ✅ *`DoesNotPerformAssertionsToTestoRector` (attribute rename)* | ➖ | | **Mocks** (`createMock`/`getMockBuilder`/`prophesize`) | ➖ | ⛔ *Testo has no built-in mocking* | ➖ | | **Memory-leak expectations** | ⛔ *no PHPUnit equivalent* | ➖ | ➖ | -| **Retry / Repeat** (`#[Retry]`/`#[Repeat]`) | ⛔ *no PHPUnit equivalent* | ➖ | ➖ | +| **Retry / Repeat** (`#[Retry]`/`#[Repeat]`) | 🟡 *`RepeatRetryRector` converts `#[\Testo\Repeat]`/`#[\Testo\Retry]` → PHPUnit `#[Repeat]`/`#[Retry]` (PHPUnit 13.3+): `maxFailures`→`failureThreshold` (+1), Testo defaults made explicit. PHPUnit's are `TARGET_METHOD` only, so a class-level Testo attribute is fanned out onto each test method (a method's own attribute overrides it, not doubled); `markFlaky` is dropped (no PHPUnit equivalent)* | 🟡 *`RepeatRetryToTestoRector` converts `#[Repeat]`/`#[Retry]` → Testo's attributes: `failureThreshold`→`maxFailures` (−1; the default 1 folds to Testo's default 0 and is omitted)* | ➖ | | **Fiber** (`#[RunInFiber]`, `Coroutine::spawn/await/concurrently`) | ⛔ *no PHPUnit/Pest equivalent — neither has a fiber/coroutine test attribute or an in-test coroutine scope* | ➖ | ➖ | | **HTML report** (`HtmlPlugin`, `--log-html`) | ⛔ *not test code — a reporter configured in `testo.php` or by a flag, with nothing in a test file to convert* | ➖ | ➖ | | **`uses()`** (Pest) | ➖ | ➖ | ⛔ *a converted function has no base class, traits or `$this` to attach to; closures that capture `$this`-shared state are left untouched* | @@ -102,7 +102,9 @@ attributes / body statements. It bails (leaves the statement untouched) on a non a `use (...)`-capturing closure, or any unrecognised modifier — see `src/PestToTesto/TODO.md`. The remaining ⛔ rows are intentionally out of scope: a missing target feature (mocking, `arch()`, -memory-leak, retry/repeat, PHPUnit `assertThat` constraints), the substring-vs-regex +memory-leak, PHPUnit `assertThat` constraints), the substring-vs-regex exception-message mismatch, or Pest `uses()` (a function has no base class / traits / `$this`). +Retry/Repeat moved off this list: PHPUnit 13.3 added `#[Repeat]`/`#[Retry]`, so both directions now +convert as a documented 🟡 (`RepeatRetryRector` / `RepeatRetryToTestoRector`). PHPUnit's `markTestIncomplete` moved off this list — it now converts to a Skipped throw with an `Incomplete: ` reason prefix (`MarkTestIncompleteRector`), a documented lossy 🟡 rather than a ⛔. diff --git a/bridge/rector/config/phpunit-to-testo.php b/bridge/rector/config/phpunit-to-testo.php index e215a1af..e20bfc27 100644 --- a/bridge/rector/config/phpunit-to-testo.php +++ b/bridge/rector/config/phpunit-to-testo.php @@ -15,6 +15,8 @@ use Testo\Bridge\Rector\PhpunitToTesto\MarkTestIncompleteRector; use Testo\Bridge\Rector\PhpunitToTesto\MarkTestSkippedToTestoRector; use Testo\Bridge\Rector\PhpunitToTesto\MergeAssertChainRector; +use Testo\Bridge\Rector\PhpunitToTesto\RepeatRetryToTestoRector; +use Testo\Bridge\Rector\PhpunitToTesto\TypedAssertCallToTestoRector; /** * PHPUnit -> Testo conversion set. @@ -27,6 +29,11 @@ */ return static function (RectorConfig $rectorConfig): void { $rectorConfig->rule(AssertCallToTestoRector::class); + + # Assertions that map onto a typed Assert head + matcher (comparisons, array keys, canonicalizing, + # emptiness) rather than a flat facade call — see TypedAssertCallToTestoRector. + $rectorConfig->rule(TypedAssertCallToTestoRector::class); + $rectorConfig->rule(MarkTestSkippedToTestoRector::class); # Incomplete has no exact Testo status; mapped to a Skipped throw with an "Incomplete:" reason @@ -51,4 +58,7 @@ # Cleanup pass: collapse adjacent `Assert::($var)->…` chains on the same variable into one. $rectorConfig->rule(MergeAssertChainRector::class); + + # Repeat/Retry method attributes (PHPUnit 13.3+) map onto Testo's #[Repeat]/#[Retry]. + $rectorConfig->rule(RepeatRetryToTestoRector::class); }; diff --git a/bridge/rector/config/testo-to-phpunit.php b/bridge/rector/config/testo-to-phpunit.php index 1a662c5b..d660c315 100644 --- a/bridge/rector/config/testo-to-phpunit.php +++ b/bridge/rector/config/testo-to-phpunit.php @@ -13,6 +13,7 @@ use Testo\Bridge\Rector\TestoToPhpunit\GroupInheritanceToPhpUnitRector; use Testo\Bridge\Rector\TestoToPhpunit\GroupToPhpUnitRector; use Testo\Bridge\Rector\TestoToPhpunit\LifecycleAttributesToPhpUnitRector; +use Testo\Bridge\Rector\TestoToPhpunit\RepeatRetryRector; use Testo\Bridge\Rector\TestoToPhpunit\TestClassToTestCaseRector; use Testo\Bridge\Rector\TestoToPhpunit\ThrowSkipTestToPhpUnitRector; use Testo\Bridge\Rector\TestoToPhpunit\TypedAssertChainRector; @@ -37,6 +38,9 @@ $rectorConfig->rule(ExpectNoAssertionsToPhpUnitRector::class); $rectorConfig->rule(DataProviderToPhpUnitRector::class); + # Repeat/Retry method attributes map onto PHPUnit's #[Repeat]/#[Retry] (PHPUnit 13.3+). + $rectorConfig->rule(RepeatRetryRector::class); + # Structural: attach PHPUnit's TestCase base class and convert #[\Testo\Test] discovery. $rectorConfig->rule(TestClassToTestCaseRector::class); diff --git a/bridge/rector/src/PhpunitToTesto/RepeatRetryToTestoRector.php b/bridge/rector/src/PhpunitToTesto/RepeatRetryToTestoRector.php new file mode 100644 index 00000000..67736add --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/RepeatRetryToTestoRector.php @@ -0,0 +1,123 @@ +isName($node->name, 'PHPUnit\\Framework\\Attributes\\Repeat')) { + return $this->convertRepeat($node); + } + + if ($this->isName($node->name, 'PHPUnit\\Framework\\Attributes\\Retry')) { + return $this->convertRetry($node); + } + + return null; + } + + private function convertRepeat(Attribute $node): ?Attribute + { + $times = $node->args[0] ?? null; + if (!$times instanceof Arg) { + return null; + } + + $args = [new Arg($times->value, name: new Identifier('times'))]; + + $threshold = $node->args[1] ?? null; + if ($threshold instanceof Arg && ($maxFailures = $this->thresholdToMaxFailures($threshold->value)) !== null) { + $args[] = new Arg($maxFailures, name: new Identifier('maxFailures')); + } + + $node->name = new FullyQualified('Testo\\Repeat'); + $node->args = $args; + + return $node; + } + + private function convertRetry(Attribute $node): ?Attribute + { + $maxAttempts = $node->args[0] ?? null; + if (!$maxAttempts instanceof Arg) { + return null; + } + + $node->name = new FullyQualified('Testo\\Retry'); + $node->args = [new Arg($maxAttempts->value, name: new Identifier('maxAttempts'))]; + + return $node; + } + + /** + * `failureThreshold - 1` as a Testo `maxFailures` expression, or null when it collapses to the + * default `0` (a literal threshold of `1` or less) and should be omitted. + */ + private function thresholdToMaxFailures(Node\Expr $threshold): ?Node\Expr + { + if ($threshold instanceof Int_) { + return $threshold->value > 1 ? new Int_($threshold->value - 1) : null; + } + + return new Minus($threshold, new Int_(1)); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/RepeatRetryToTestoRector/repeat_threshold_one_omitted.php.inc b/bridge/rector/src/PhpunitToTesto/RepeatRetryToTestoRector/repeat_threshold_one_omitted.php.inc new file mode 100644 index 00000000..a8cc71f3 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/RepeatRetryToTestoRector/repeat_threshold_one_omitted.php.inc @@ -0,0 +1,21 @@ +greaterThan($e)`, plus + `GreaterThanOrEqual`/`LessThan`/`LessThanOrEqual`), array keys (`assertArrayHasKey($k, $a)`→ + `Assert::array($a)->hasKeys($k)`, and `assertArrayNotHasKey`→`doesNotHaveKeys`), and + `assertEqualsCanonicalizing($e, $a)`→`Assert::array($a)->sameElementsAs($e)`. `assertEmpty`/ + `assertNotEmpty` map to the flat `Assert::blank()`/`notBlank()` **only for an array subject** (via + PHPStan type inference): `blank()` treats `false`/`0`/`'0'` as valid data, so those notions coincide + with PHP's `empty()` only where the subject can never be one of them — an array. A non-array (or + statically-unknown) subject is left untouched. Same "only inside a class" gate as + `AssertCallToTestoRector`. **Message residual (by design):** the numeric matchers, + `sameElementsAs()`, `blank()`/`notBlank()` all keep a trailing `$message` — but the array-key + matchers (`hasKeys`/`doesNotHaveKeys`) are variadic with no message parameter, so a PHPUnit message + on `assertArrayHasKey`/`assertArrayNotHasKey` is dropped (mirrors the reverse direction, which emits + keyed assertions without a message). `assertNotEqualsCanonicalizing` has no counterpart (there is no + `notSameElementsAs`) and is left untouched. +- **RepeatRetryToTestoRector** (registered) — converts PHPUnit's `#[Repeat]` / `#[Retry]` method + attributes (PHPUnit 13.3+) into `#[\Testo\Repeat]` / `#[\Testo\Retry]`. `times`/`maxAttempts` carry + over verbatim; PHPUnit's `failureThreshold` (aborting failure count, default 1) maps to Testo's + `maxFailures` (tolerated failures, default 0) as `maxFailures = failureThreshold - 1`, and the + PHPUnit default 1 folds to the Testo default 0 (omitted). Faithful with no target reconciliation — + PHPUnit's attributes are method-only and Testo's accept a strict superset of targets. The imperative body rules — `AssertCallToTestoRector`, `ExpectExceptionToTestoRector`, `MarkTestSkippedToTestoRector`, `MarkTestIncompleteRector`, `MergeAssertChainRector` — fire **only diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector.php b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector.php new file mode 100644 index 00000000..f5151b1d --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector.php @@ -0,0 +1,196 @@ +assertGreaterThan($e, $a) → \Testo\Assert::numeric($a)->greaterThan($e) + * - $this->assertGreaterThanOrEqual(...) → …->greaterThanOrEqual(…) + * - $this->assertLessThan(...) → …->lessThan(…) + * - $this->assertLessThanOrEqual(...) → …->lessThanOrEqual(…) + * - $this->assertArrayHasKey($k, $a) → \Testo\Assert::array($a)->hasKeys($k) + * - $this->assertArrayNotHasKey($k, $a) → \Testo\Assert::array($a)->doesNotHaveKeys($k) + * - $this->assertEqualsCanonicalizing($e, $a) → \Testo\Assert::array($a)->sameElementsAs($e) + * + * `assertEmpty`/`assertNotEmpty` map to the flat `\Testo\Assert::blank()`/`notBlank()` — but only when + * the subject's inferred type is an array. Testo's `blank()` treats `false`/`0`/`'0'` as valid + * (non-blank) data, so converting a call whose subject could be one of those would change meaning; + * an array can never be `false`/`0`/`'0'`, so there the two notions coincide and the rewrite is + * faithful. A non-array (or unknown) subject is left untouched — see TODO.md. + * + * Message residual: the numeric matchers and `sameElementsAs()`/`blank()`/`notBlank()` all keep a + * trailing `$message`, so it is preserved there. The array-key matchers (`hasKeys()`/ + * `doesNotHaveKeys()`) are variadic with no `$message` parameter, so a PHPUnit message on + * `assertArrayHasKey`/`assertArrayNotHasKey` is dropped (documented in TODO.md; mirrors the reverse + * direction, which emits keyed assertions without a message too). + * + * Only rewrites a call inside a class (a test method or a `static` data provider), mirroring + * {@see AssertCallToTestoRector}; a matching call in a free function or at namespace level is left + * untouched. + */ +#[TestRectorFixtures('TypedAssertCallToTestoRector')] +final class TypedAssertCallToTestoRector extends AbstractRector +{ + /** + * Comparison assertions → the `Assert::numeric($subject)->…` matcher. `$message` is preserved. + * + * @var array + */ + private const NUMERIC = [ + 'assertGreaterThan' => 'greaterThan', + 'assertGreaterThanOrEqual' => 'greaterThanOrEqual', + 'assertLessThan' => 'lessThan', + 'assertLessThanOrEqual' => 'lessThanOrEqual', + ]; + + /** + * Array-key assertions → the `Assert::array($subject)->…` matcher. `$message` is dropped + * (the matchers are variadic with no message parameter). + * + * @var array + */ + private const ARRAY_KEY = [ + 'assertArrayHasKey' => 'hasKeys', + 'assertArrayNotHasKey' => 'doesNotHaveKeys', + ]; + + public function getRuleDefinition(): RuleDefinition + { + return new RuleDefinition( + 'Convert PHPUnit comparison / array-key / canonicalizing / emptiness assertions into Testo typed Assert chains', + [ + new CodeSample( + <<<'PHP' + $this->assertGreaterThan(0, $n); + $this->assertArrayHasKey('id', $row); + PHP, + <<<'PHP' + \Testo\Assert::numeric($n)->greaterThan(0); + \Testo\Assert::array($row)->hasKeys('id'); + PHP, + ), + ], + ); + } + + #[\Override] + public function getNodeTypes(): array + { + return [MethodCall::class, StaticCall::class]; + } + + /** + * @param MethodCall|StaticCall $node + */ + #[\Override] + public function refactor(Node $node): ?Node + { + if ($node instanceof MethodCall) { + if (!$this->isName($node->var, 'this')) { + return null; + } + } elseif (!$this->isName($node->class, 'self') && !$this->isName($node->class, 'static')) { + return null; + } + + $method = $this->getName($node->name); + if ($method === null) { + return null; + } + + # An assertion belongs to a test method (or a static data provider); a stray call in a free + # function or at namespace level is left as-is — same gate as AssertCallToTestoRector. + if (!$this->isInClassScope($node)) { + return null; + } + + return match (true) { + isset(self::NUMERIC[$method]) => $this->typedChain('numeric', self::NUMERIC[$method], $node->args, keepMessage: true), + isset(self::ARRAY_KEY[$method]) => $this->typedChain('array', self::ARRAY_KEY[$method], $node->args, keepMessage: false), + $method === 'assertEqualsCanonicalizing' => $this->typedChain('array', 'sameElementsAs', $node->args, keepMessage: true), + $method === 'assertEmpty' => $this->emptiness('blank', $node->args), + $method === 'assertNotEmpty' => $this->emptiness('notBlank', $node->args), + default => null, + }; + } + + /** + * `assert*($needle, $subject[, $message])` → `Assert::($subject)->($needle[, $message])`. + * + * @param non-empty-string $head The Testo `Assert::()` type check. + * @param non-empty-string $matcher The chained matcher method. + * @param array $args + * @param bool $keepMessage Whether the matcher accepts (and should keep) the trailing `$message`. + */ + private function typedChain(string $head, string $matcher, array $args, bool $keepMessage): ?MethodCall + { + $needle = $args[0] ?? null; + $subject = $args[1] ?? null; + if (!$needle instanceof Arg || !$subject instanceof Arg) { + return null; + } + + $matcherArgs = [$needle]; + if ($keepMessage && ($args[2] ?? null) instanceof Arg) { + $matcherArgs[] = $args[2]; + } + + return new MethodCall( + new StaticCall(new FullyQualified('Testo\\Assert'), new Identifier($head), [$subject]), + new Identifier($matcher), + $matcherArgs, + ); + } + + /** + * `assertEmpty($subject[, $message])` → `Assert::blank($subject[, $message])` (and `notBlank` for + * `assertNotEmpty`), but only for an array subject where `blank()` and PHP's `empty()` coincide. + * + * @param non-empty-string $testoMethod + * @param array $args + */ + private function emptiness(string $testoMethod, array $args): ?StaticCall + { + $subject = $args[0] ?? null; + if (!$subject instanceof Arg || !$this->getType($subject->value)->isArray()->yes()) { + return null; + } + + $callArgs = [$subject]; + if (($args[1] ?? null) instanceof Arg) { + $callArgs[] = $args[1]; + } + + return new StaticCall(new FullyQualified('Testo\\Assert'), new Identifier($testoMethod), $callArgs); + } + + /** + * Whether $node sits inside a class. Outside one the assertion is left untouched. + */ + private function isInClassScope(Node $node): bool + { + $scope = $node->getAttribute(AttributeKey::SCOPE); + + return $scope instanceof Scope && $scope->isInClass(); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/array_key.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/array_key.php.inc new file mode 100644 index 00000000..b69fb738 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/array_key.php.inc @@ -0,0 +1,21 @@ +assertArrayHasKey('id', $row); + $this->assertArrayNotHasKey('secret', $row, 'this message is dropped'); + } +} +----- +hasKeys('id'); + \Testo\Assert::array($row)->doesNotHaveKeys('secret'); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/comparison_group.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/comparison_group.php.inc new file mode 100644 index 00000000..7b2434a5 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/comparison_group.php.inc @@ -0,0 +1,25 @@ +assertGreaterThan(0, $n); + $this->assertGreaterThanOrEqual(1, $n); + $this->assertLessThan(100, $n); + $this->assertLessThanOrEqual(99, $n, 'must fit'); + } +} +----- +greaterThan(0); + \Testo\Assert::numeric($n)->greaterThanOrEqual(1); + \Testo\Assert::numeric($n)->lessThan(100); + \Testo\Assert::numeric($n)->lessThanOrEqual(99, 'must fit'); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/empty_array_subject.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/empty_array_subject.php.inc new file mode 100644 index 00000000..d601150e --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/empty_array_subject.php.inc @@ -0,0 +1,21 @@ +assertEmpty($rows); + $this->assertNotEmpty($rows, 'must have rows'); + } +} +----- +assertEmpty($value); + $this->assertNotEmpty($value); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/equals_canonicalizing.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/equals_canonicalizing.php.inc new file mode 100644 index 00000000..400f033b --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/equals_canonicalizing.php.inc @@ -0,0 +1,21 @@ +assertEqualsCanonicalizing([1, 2, 3], $actual); + $this->assertEqualsCanonicalizing($expected, $actual, 'same set'); + } +} +----- +sameElementsAs([1, 2, 3]); + \Testo\Assert::array($actual)->sameElementsAs($expected, 'same set'); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/outside_method_left_unchanged.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/outside_method_left_unchanged.php.inc new file mode 100644 index 00000000..be9a599f --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/outside_method_left_unchanged.php.inc @@ -0,0 +1,8 @@ +assertGreaterThan(0, $n); + $this->assertArrayHasKey('id', $row); +} diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/self_static_call.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/self_static_call.php.inc new file mode 100644 index 00000000..0078c08f --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/self_static_call.php.inc @@ -0,0 +1,21 @@ +greaterThan(0); + \Testo\Assert::array($row)->hasKeys('id'); + } +} diff --git a/bridge/rector/src/TestoToPhpunit/AssertCallToPhpUnitRector.php b/bridge/rector/src/TestoToPhpunit/AssertCallToPhpUnitRector.php index 7558e1e4..5f727b9f 100644 --- a/bridge/rector/src/TestoToPhpunit/AssertCallToPhpUnitRector.php +++ b/bridge/rector/src/TestoToPhpunit/AssertCallToPhpUnitRector.php @@ -40,9 +40,10 @@ * outside object context". * * Methods with no faithful PHPUnit counterpart (fluent type assertions such as - * `Assert::string()`/`int()`/`json()`, and `blank()`) are intentionally left + * `Assert::string()`/`int()`/`json()`, and `blank()`/`notBlank()`) are intentionally left * untouched, so the surrounding test stays visibly unconverted instead of being - * silently mistranslated. + * silently mistranslated. (`blank()`/`notBlank()` deliberately treat `false`/`0`/`'0'` as valid + * data, unlike PHPUnit's `assertEmpty`/`assertNotEmpty`, so a blind swap would change meaning.) */ #[TestRectorFixtures('AssertCallToPhpUnitRector')] final class AssertCallToPhpUnitRector extends AbstractRector diff --git a/bridge/rector/src/TestoToPhpunit/RepeatRetryRector.php b/bridge/rector/src/TestoToPhpunit/RepeatRetryRector.php index 36676b3b..98062a13 100644 --- a/bridge/rector/src/TestoToPhpunit/RepeatRetryRector.php +++ b/bridge/rector/src/TestoToPhpunit/RepeatRetryRector.php @@ -5,40 +5,336 @@ namespace Testo\Bridge\Rector\TestoToPhpunit; use PhpParser\Node; +use PhpParser\Node\Arg; +use PhpParser\Node\Attribute; +use PhpParser\Node\AttributeGroup; +use PhpParser\Node\Expr\BinaryOp\Plus; +use PhpParser\Node\Name\FullyQualified; +use PhpParser\Node\Identifier; +use PhpParser\Node\Scalar\Int_; +use PhpParser\Node\Stmt\Class_; +use PhpParser\Node\Stmt\ClassMethod; use Rector\Rector\AbstractRector; +use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample; use Symplify\RuleDocGenerator\ValueObject\RuleDefinition; +use Testo\Bridge\Rector\Testing\TestRectorFixtures; /** - * STUB — not implemented, not registered in the set. + * Converts Testo's `#[\Testo\Repeat]` / `#[\Testo\Retry]` attributes into PHPUnit's `#[Repeat]` / + * `#[Retry]` (available since PHPUnit 13.3): * - * Intent: convert Testo's `#[Testo\Repeat\Repeat]` / `#[Testo\Retry\Retry]` - * (RetryPolicy) attributes into a PHPUnit equivalent. + * - #[\Testo\Repeat(times: N)] → #[\PHPUnit\Framework\Attributes\Repeat(N)] + * - #[\Testo\Repeat(times: N, maxFailures: M)] → #[\PHPUnit\Framework\Attributes\Repeat(N, M + 1)] + * - #[\Testo\Retry(maxAttempts: N)] → #[\PHPUnit\Framework\Attributes\Retry(N)] * - * @todo No faithful PHPUnit equivalent exists in core PHPUnit. Testo can repeat a - * test a fixed number of times and retry flaky tests according to a policy; - * PHPUnit core has no `#[Repeat]`/`#[Retry]` attribute and no built-in retry - * loop (such behaviour only exists via third-party extensions with diverging - * semantics). Mapping is therefore lossy and is left for manual conversion. + * Testo's `maxFailures` (tolerated failures, default 0) maps to PHPUnit's `failureThreshold` + * (aborting failure count, default 1) as `failureThreshold = maxFailures + 1`; the Testo default 0 + * folds back to PHPUnit's default 1 and is omitted. PHPUnit's attributes are positional + * (`@no-named-arguments`), so the emitted arguments carry no names. Testo's defaults are made + * explicit where PHPUnit has no matching default drop (`times` → `2`, `maxAttempts` → `3`). + * + * Testo allows the attribute on a **class** too; PHPUnit's `Repeat`/`Retry` are `TARGET_METHOD` + * only, so a class-level attribute is **fanned out onto each test method** (mirroring how Testo + * applies it to every test in the class) and removed from the class. A method that carries its own + * `#[Repeat]`/`#[Retry]` keeps it — a method-level attribute overrides the class-level default and is + * not doubled. Test methods are found the same way as {@see TestClassToTestCaseRector}: the + * `#[Test]`-marked methods (Testo's or the already-converted PHPUnit form), or — under a class-level + * `#[\Testo\Test]` — every public, non-static, `void`/`never`, non-lifecycle method. + * + * **Residual:** Testo's `markFlaky` flag is dropped — PHPUnit has no flaky-marking equivalent. */ +#[TestRectorFixtures('RepeatRetryRector')] final class RepeatRetryRector extends AbstractRector { + private const REPEAT_TESTO = 'Testo\\Repeat'; + private const RETRY_TESTO = 'Testo\\Retry'; + private const REPEAT_PHPUNIT = 'PHPUnit\\Framework\\Attributes\\Repeat'; + private const RETRY_PHPUNIT = 'PHPUnit\\Framework\\Attributes\\Retry'; + + /** @var list */ + private const LIFECYCLE_ATTRIBUTES = [ + 'Testo\\Lifecycle\\BeforeTest', + 'Testo\\Lifecycle\\AfterTest', + 'Testo\\Lifecycle\\BeforeClass', + 'Testo\\Lifecycle\\AfterClass', + 'PHPUnit\\Framework\\Attributes\\Before', + 'PHPUnit\\Framework\\Attributes\\After', + 'PHPUnit\\Framework\\Attributes\\BeforeClass', + 'PHPUnit\\Framework\\Attributes\\AfterClass', + ]; + + /** @var list */ + private const LIFECYCLE_NAMES = ['setup', 'teardown', 'setupbeforeclass', 'teardownafterclass']; + public function getRuleDefinition(): RuleDefinition { return new RuleDefinition( - 'STUB: Testo #[Repeat]/#[Retry] attributes have no faithful PHPUnit-core equivalent (not implemented)', - [], + 'Convert Testo #[Repeat]/#[Retry] attributes into PHPUnit #[Repeat]/#[Retry], fanning a class-level attribute out onto each test method', + [ + new CodeSample( + <<<'PHP' + #[\Testo\Repeat(times: 5, maxFailures: 1)] + public function test(): void {} + PHP, + <<<'PHP' + #[\PHPUnit\Framework\Attributes\Repeat(5, 2)] + public function test(): void {} + PHP, + ), + ], ); } #[\Override] public function getNodeTypes(): array { - return []; + return [Class_::class]; } + /** + * @param Class_ $node + */ #[\Override] public function refactor(Node $node): ?Node { + # 1. Method-level attributes: convert each in place. + $changed = false; + foreach ($node->getMethods() as $method) { + foreach ($method->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if ($this->isName($attr->name, self::REPEAT_TESTO)) { + $attr->args = $this->repeatArgs($attr); + $attr->name = new FullyQualified(self::REPEAT_PHPUNIT); + $changed = true; + } elseif ($this->isName($attr->name, self::RETRY_TESTO)) { + $attr->args = $this->retryArgs($attr); + $attr->name = new FullyQualified(self::RETRY_PHPUNIT); + $changed = true; + } + } + } + } + + # 2. Class-level attributes: PHPUnit has no class target, so fan out onto each test method. + return $this->fanOutClassLevel($node) || $changed ? $node : null; + } + + /** + * Push a class-level `#[\Testo\Repeat]`/`#[\Testo\Retry]` down onto each test method as its PHPUnit + * form and drop it from the class. A method already carrying its own attribute of the same kind is + * left alone (method-level overrides the class-level default). Returns whether anything changed. + */ + private function fanOutClassLevel(Class_ $class): bool + { + $repeat = $this->classAttribute($class, self::REPEAT_TESTO); + $retry = $this->classAttribute($class, self::RETRY_TESTO); + if ($repeat === null && $retry === null) { + return false; + } + + $targets = $this->testMethods($class); + if ($targets === []) { + # Nothing to carry the attribute (not a test class here); leave it untouched. + return false; + } + + foreach ($targets as $method) { + if ($repeat !== null && !$this->methodHasKind($method, self::REPEAT_TESTO, self::REPEAT_PHPUNIT)) { + $method->attrGroups[] = new AttributeGroup([ + new Attribute(new FullyQualified(self::REPEAT_PHPUNIT), $this->repeatArgs($repeat)), + ]); + } + if ($retry !== null && !$this->methodHasKind($method, self::RETRY_TESTO, self::RETRY_PHPUNIT)) { + $method->attrGroups[] = new AttributeGroup([ + new Attribute(new FullyQualified(self::RETRY_PHPUNIT), $this->retryArgs($retry)), + ]); + } + } + + $this->removeClassAttributes($class, [self::REPEAT_TESTO, self::RETRY_TESTO]); + + return true; + } + + /** The first class-level attribute with the given name, or null. */ + private function classAttribute(Class_ $class, string $name): ?Attribute + { + foreach ($class->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if ($this->isName($attr->name, $name)) { + return $attr; + } + } + } + return null; } + + /** + * @param list $names + */ + private function removeClassAttributes(Class_ $class, array $names): void + { + $kept = []; + foreach ($class->attrGroups as $attrGroup) { + $attrGroup->attrs = \array_values(\array_filter( + $attrGroup->attrs, + fn(Attribute $attr): bool => !$this->isAnyName($attr, $names), + )); + $attrGroup->attrs === [] or $kept[] = $attrGroup; + } + + $class->attrGroups = $kept; + } + + /** + * The methods a class-level attribute should fan out to, mirroring Testo discovery: the + * `#[Test]`-marked methods when any exist (Testo's or the converted PHPUnit form), otherwise — + * under a class-level `#[\Testo\Test]` — every public, non-static, void/never, non-lifecycle method. + * + * @return list + */ + private function testMethods(Class_ $class): array + { + $marked = []; + foreach ($class->getMethods() as $method) { + if ($this->methodHasKind($method, 'Testo\\Test', 'PHPUnit\\Framework\\Attributes\\Test')) { + $marked[] = $method; + } + } + if ($marked !== []) { + return $marked; + } + + if (!$this->hasClassLevelTest($class)) { + return []; + } + + $discovered = []; + foreach ($class->getMethods() as $method) { + $this->isDiscoverableByClassLevelTest($method) and $discovered[] = $method; + } + + return $discovered; + } + + private function hasClassLevelTest(Class_ $class): bool + { + return $this->classAttribute($class, 'Testo\\Test') !== null; + } + + /** + * Mirrors Testo's locator: a public, non-static method with a `void`/`never` return type that is + * not a lifecycle hook. + */ + private function isDiscoverableByClassLevelTest(ClassMethod $method): bool + { + if (!$method->isPublic() || $method->isStatic() || $this->isLifecycleMethod($method)) { + return false; + } + + $returnType = $method->returnType; + + return $returnType instanceof Identifier && \in_array($returnType->toLowerString(), ['void', 'never'], true); + } + + private function isLifecycleMethod(ClassMethod $method): bool + { + if (\in_array(\strtolower((string) $this->getName($method)), self::LIFECYCLE_NAMES, true)) { + return true; + } + + return $this->methodHasKind($method, ...self::LIFECYCLE_ATTRIBUTES); + } + + /** Whether the method carries any attribute named among $names. */ + private function methodHasKind(ClassMethod $method, string ...$names): bool + { + foreach ($method->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if ($this->isAnyName($attr, $names)) { + return true; + } + } + } + + return false; + } + + /** + * @param list $names + */ + private function isAnyName(Attribute $attr, array $names): bool + { + foreach ($names as $name) { + if ($this->isName($attr->name, $name)) { + return true; + } + } + + return false; + } + + /** + * PHPUnit `Repeat` positional args from a Testo `#[Repeat]`: `times` (default 2) and, unless it + * collapses to the default, `failureThreshold = maxFailures + 1`. Values are cloned so the same + * source attribute can be fanned out onto several methods with independent nodes. + * + * @return list + */ + private function repeatArgs(Attribute $src): array + { + $times = $this->argValue($src->args, 'times', 0); + $args = [new Arg($times !== null ? clone $times : new Int_(2))]; + + $maxFailures = $this->argValue($src->args, 'maxFailures', 1); + if ($maxFailures !== null && ($threshold = $this->maxFailuresToThreshold(clone $maxFailures)) !== null) { + $args[] = new Arg($threshold); + } + + return $args; + } + + /** + * PHPUnit `Retry` positional args from a Testo `#[Retry]`: `maxAttempts` (default 3), cloned. + * + * @return list + */ + private function retryArgs(Attribute $src): array + { + $maxAttempts = $this->argValue($src->args, 'maxAttempts', 0); + + return [new Arg($maxAttempts !== null ? clone $maxAttempts : new Int_(3))]; + } + + /** + * `maxFailures + 1` as a PHPUnit `failureThreshold` expression, or null when it collapses to the + * default `1` (a literal `maxFailures` of `0`) and should be omitted. + */ + private function maxFailuresToThreshold(Node\Expr $maxFailures): ?Node\Expr + { + if ($maxFailures instanceof Int_) { + return $maxFailures->value > 0 ? new Int_($maxFailures->value + 1) : null; + } + + return new Plus($maxFailures, new Int_(1)); + } + + /** + * The value of the named argument `$name`, or the positional argument at `$position` when it + * carries no name; null when neither is present. + * + * @param array $args + */ + private function argValue(array $args, string $name, int $position): ?Node\Expr + { + foreach ($args as $arg) { + if ($arg instanceof Arg && $arg->name instanceof Identifier && $arg->name->toString() === $name) { + return $arg->value; + } + } + + $arg = $args[$position] ?? null; + return $arg instanceof Arg && $arg->name === null ? $arg->value : null; + } } diff --git a/bridge/rector/src/TestoToPhpunit/RepeatRetryRector/class_level_fans_out_to_methods.php.inc b/bridge/rector/src/TestoToPhpunit/RepeatRetryRector/class_level_fans_out_to_methods.php.inc new file mode 100644 index 00000000..23c06100 --- /dev/null +++ b/bridge/rector/src/TestoToPhpunit/RepeatRetryRector/class_level_fans_out_to_methods.php.inc @@ -0,0 +1,31 @@ +contains()`, `Assert::int()->between()`, `Assert::array()->hasKeys()`, - `Assert::array()->isList()`→`assertIsList`, …) into separate `assert*` statements, expanding 1→N - where needed. A non-variable subject (e.g. `Assert::array($log->all())->isList()`) is hoisted into - a scope-safe `$value` local so it is evaluated once. Matchers with no faithful PHPUnit line (JSON - path/structure, `every`, `sameSizeAs`, custom) leave the whole chain untouched rather than - half-converting. + `Assert::array()->isList()`→`assertIsList`, `Assert::array()->sameElementsAs()`→`assertEqualsCanonicalizing`, + …) into separate `assert*` statements, expanding 1→N where needed. A non-variable subject (e.g. + `Assert::array($log->all())->isList()`) is hoisted into a scope-safe `$value` local so it is + evaluated once. Matchers with no faithful PHPUnit line (JSON path/structure, `every`, `sameSizeAs`, + custom) leave the whole chain untouched rather than half-converting. +- **`RepeatRetryRector`** (registered) — converts **method-level** `#[\Testo\Repeat]` / + `#[\Testo\Retry]` into PHPUnit's `#[Repeat]` / `#[Retry]` (available since PHPUnit 13.3). Testo's + `maxFailures` (tolerated failures, default 0) maps to PHPUnit's `failureThreshold` (aborting failure + count, default 1) as `failureThreshold = maxFailures + 1`; the Testo default 0 folds back to the + PHPUnit default 1 and is omitted. Testo's defaults are made explicit where PHPUnit lacks a matching + drop (`times`→2, `maxAttempts`→3), and the emitted arguments are positional (PHPUnit's attributes are + `@no-named-arguments`). PHPUnit's `Repeat`/`Retry` are `TARGET_METHOD` only, so a **class-level** Testo + attribute is fanned out onto each test method (mirroring how Testo applies it to every test in the + class) and removed from the class; a method carrying its own attribute of the same kind keeps it + (method-level overrides the class default, not doubled). Test methods are found as in + `TestClassToTestCaseRector` — the `#[Test]`-marked methods, or under a class-level `#[\Testo\Test]` + every public non-static `void`/`never` non-lifecycle method. **Residual:** Testo's `markFlaky` flag is + dropped (no PHPUnit equivalent); a function-level attribute (a free-function test) has no PHPUnit + target and is left untouched. All four imperative body rules — `AssertCallToPhpUnitRector`, `TypedAssertChainRector`, `ExpectExceptionToPhpUnitRector`, `ThrowSkipTestToPhpUnitRector` — now fire **only inside a class** diff --git a/bridge/rector/src/TestoToPhpunit/TypedAssertChainRector.php b/bridge/rector/src/TestoToPhpunit/TypedAssertChainRector.php index 47247319..203d50e3 100644 --- a/bridge/rector/src/TestoToPhpunit/TypedAssertChainRector.php +++ b/bridge/rector/src/TestoToPhpunit/TypedAssertChainRector.php @@ -12,6 +12,7 @@ use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Identifier; +use PhpParser\Node\Name; use PhpParser\Node\Stmt\Expression; use PhpParser\Node\VariadicPlaceholder; use PHPStan\Analyser\Scope; @@ -129,6 +130,11 @@ public function refactor(Node $node): ?array return null; } + # Emit `$this->assert*` where `$this` is bound, but `self::assert*` where it is not — a static + # method, a static closure or a data provider — since PHPUnit's assertions are static and + # `$this` there is a fatal "using $this when not in object context". + $useThis = $this->isThisAvailable($node); + $headArg = $cursor->args[0] ?? null; if (!$headArg instanceof Arg) { return null; @@ -145,11 +151,11 @@ public function refactor(Node $node): ?array $subject = $variable; } - $stmts = [...$prefix, $this->assertStmt($assertIs, [$this->arg($subject)])]; + $stmts = [...$prefix, $this->assertStmt($assertIs, [$this->arg($subject)], $useThis)]; foreach (\array_reverse($links) as $link) { $matcher = $this->getName($link->name); - $mapped = $matcher === null ? null : $this->mapMatcher($type, $matcher, $link->args, $subject); + $mapped = $matcher === null ? null : $this->mapMatcher($type, $matcher, $link->args, $subject, $useThis); if ($mapped === null) { # Any unmapped matcher aborts the whole conversion — never half-convert. return null; @@ -191,6 +197,17 @@ private function isInClassScope(Expression $node): bool return $scope instanceof Scope && $scope->isInClass(); } + /** + * Whether `$this` is bound in the scope of $node — false inside a static method, a static closure + * or a data provider, where the assertions must be emitted as `self::assert*` instead. + */ + private function isThisAvailable(Expression $node): bool + { + $scope = $node->getAttribute(AttributeKey::SCOPE); + + return $scope instanceof Scope && $scope->hasVariableType('this')->yes(); + } + /** * Maps one Testo matcher (with its arguments) to one or more PHPUnit assertion statements, * or null when the matcher has no faithful PHPUnit equivalent. @@ -200,21 +217,21 @@ private function isInClassScope(Expression $node): bool * @param array $args * @return list|null */ - private function mapMatcher(string $type, string $matcher, array $args, Expr $value): ?array + private function mapMatcher(string $type, string $matcher, array $args, Expr $value, bool $useThis): ?array { $first = ($args[0] ?? null) instanceof Arg ? $args[0]->value : null; # `assertX($needle, $value)` — the common "subject is the last argument" shape. $needleFirst = fn(string $assert): ?array => $first === null ? null - : [$this->assertStmt($assert, [$this->arg($first), $this->arg($value)])]; + : [$this->assertStmt($assert, [$this->arg($first), $this->arg($value)], $useThis)]; return match (true) { ($type === 'int' || $type === 'float') && $matcher === 'greaterThan' => $needleFirst('assertGreaterThan'), ($type === 'int' || $type === 'float') && $matcher === 'greaterThanOrEqual' => $needleFirst('assertGreaterThanOrEqual'), ($type === 'int' || $type === 'float') && $matcher === 'lessThan' => $needleFirst('assertLessThan'), ($type === 'int' || $type === 'float') && $matcher === 'lessThanOrEqual' => $needleFirst('assertLessThanOrEqual'), - ($type === 'int' || $type === 'float') && $matcher === 'between' => $this->between($args, $value), + ($type === 'int' || $type === 'float') && $matcher === 'between' => $this->between($args, $value, $useThis), $type === 'string' && $matcher === 'contains' => $needleFirst('assertStringContainsString'), $type === 'string' && $matcher === 'notContains' => $needleFirst('assertStringNotContainsString'), @@ -222,10 +239,11 @@ private function mapMatcher(string $type, string $matcher, array $args, Expr $va $type === 'array' && $matcher === 'contains' => $needleFirst('assertContains'), $type === 'array' && $matcher === 'notContains' => $needleFirst('assertNotContains'), $type === 'array' && $matcher === 'hasCount' => $needleFirst('assertCount'), - $type === 'array' && $matcher === 'notEmpty' => [$this->assertStmt('assertNotEmpty', [$this->arg($value)])], - $type === 'array' && $matcher === 'isList' => [$this->assertStmt('assertIsList', [$this->arg($value)])], - $type === 'array' && $matcher === 'hasKeys' => $this->keys('assertArrayHasKey', $args, $value), - $type === 'array' && $matcher === 'doesNotHaveKeys' => $this->keys('assertArrayNotHasKey', $args, $value), + $type === 'array' && $matcher === 'notEmpty' => [$this->assertStmt('assertNotEmpty', [$this->arg($value)], $useThis)], + $type === 'array' && $matcher === 'isList' => [$this->assertStmt('assertIsList', [$this->arg($value)], $useThis)], + $type === 'array' && $matcher === 'hasKeys' => $this->keys('assertArrayHasKey', $args, $value, $useThis), + $type === 'array' && $matcher === 'doesNotHaveKeys' => $this->keys('assertArrayNotHasKey', $args, $value, $useThis), + $type === 'array' && $matcher === 'sameElementsAs' => $needleFirst('assertEqualsCanonicalizing'), $type === 'object' && $matcher === 'instanceOf' => $needleFirst('assertInstanceOf'), $type === 'object' && $matcher === 'hasProperty' => $needleFirst('assertObjectHasProperty'), @@ -240,7 +258,7 @@ private function mapMatcher(string $type, string $matcher, array $args, Expr $va * @param array $args * @return list|null */ - private function between(array $args, Expr $value): ?array + private function between(array $args, Expr $value, bool $useThis): ?array { $lo = ($args[0] ?? null) instanceof Arg ? $args[0]->value : null; $hi = ($args[1] ?? null) instanceof Arg ? $args[1]->value : null; @@ -249,8 +267,8 @@ private function between(array $args, Expr $value): ?array } return [ - $this->assertStmt('assertGreaterThanOrEqual', [$this->arg($lo), $this->arg($value)]), - $this->assertStmt('assertLessThanOrEqual', [$this->arg($hi), $this->arg($value)]), + $this->assertStmt('assertGreaterThanOrEqual', [$this->arg($lo), $this->arg($value)], $useThis), + $this->assertStmt('assertLessThanOrEqual', [$this->arg($hi), $this->arg($value)], $useThis), ]; } @@ -261,26 +279,32 @@ private function between(array $args, Expr $value): ?array * @param array $args * @return list|null */ - private function keys(string $assert, array $args, Expr $value): ?array + private function keys(string $assert, array $args, Expr $value, bool $useThis): ?array { $stmts = []; foreach ($args as $arg) { if (!$arg instanceof Arg) { return null; } - $stmts[] = $this->assertStmt($assert, [$this->arg($arg->value), $this->arg($value)]); + $stmts[] = $this->assertStmt($assert, [$this->arg($arg->value), $this->arg($value)], $useThis); } return $stmts === [] ? null : $stmts; } /** + * One `$this->assert*()` (or `self::assert*()` where `$this` is unavailable) statement. + * * @param non-empty-string $method * @param list $args */ - private function assertStmt(string $method, array $args): Expression + private function assertStmt(string $method, array $args, bool $useThis): Expression { - return new Expression(new MethodCall(new Variable('this'), new Identifier($method), $args)); + $call = $useThis + ? new MethodCall(new Variable('this'), new Identifier($method), $args) + : new StaticCall(new Name('self'), new Identifier($method), $args); + + return new Expression($call); } /** diff --git a/bridge/rector/src/TestoToPhpunit/TypedAssertChainRector/array_same_elements.php.inc b/bridge/rector/src/TestoToPhpunit/TypedAssertChainRector/array_same_elements.php.inc new file mode 100644 index 00000000..4eebac9e --- /dev/null +++ b/bridge/rector/src/TestoToPhpunit/TypedAssertChainRector/array_same_elements.php.inc @@ -0,0 +1,20 @@ +sameElementsAs([1, 2, 3]); + } +} +----- +assertIsArray($a); + $this->assertEqualsCanonicalizing([1, 2, 3], $a); + } +} diff --git a/bridge/rector/src/TestoToPhpunit/TypedAssertChainRector/static_context_uses_self.php.inc b/bridge/rector/src/TestoToPhpunit/TypedAssertChainRector/static_context_uses_self.php.inc new file mode 100644 index 00000000..a2c6836c --- /dev/null +++ b/bridge/rector/src/TestoToPhpunit/TypedAssertChainRector/static_context_uses_self.php.inc @@ -0,0 +1,26 @@ +contains('foo'); + }; + } +} +----- + PHPUnit"). Not the primary test runner — `composer test` (Testo) is. + + executionOrder must NOT include `defects`: Infection disables test-run-history recording for its + initial coverage run (it injects cacheResult="false"), and since PHPUnit 13.3 defect ordering + requires that history — with it off the runner orders by nothing and executes zero tests, so + Infection collects empty coverage and aborts with "No source code was executed". --> value) == self::canonicalize($expected)) { + $this->parent->success($str); + return $this; + } + + throw $this->parent->fail( + assertion: $str, + reason: 'the elements differ regardless of order', + context: $message, + ); + } + + /** + * Recursively sorts an array by value and discards keys, so two arrays holding the same + * elements in any order (and under any keys) canonicalize to an identical shape. Mirrors the + * canonicalization PHPUnit applies for `assertEqualsCanonicalizing`. + */ + private static function canonicalize(array $value): array + { + foreach ($value as &$item) { + if (\is_array($item)) { + $item = self::canonicalize($item); + } + } + unset($item); + + \sort($value); + return $value; + } } diff --git a/plugin/assert/src/Internal/Assertion/AssertNumeric.php b/plugin/assert/src/Internal/Assertion/AssertNumeric.php index dc80795b..3bb34fb6 100644 --- a/plugin/assert/src/Internal/Assertion/AssertNumeric.php +++ b/plugin/assert/src/Internal/Assertion/AssertNumeric.php @@ -4,17 +4,23 @@ namespace Testo\Assert\Internal\Assertion; -use Testo\Assert\Api\Builtin\IntType; +use Testo\Assert\Api\Builtin\NumericType; use Testo\Assert\Internal\Assertion\Traits\NumericTrait; +use Testo\Assert\Internal\StaticState; use Testo\Assert\State\Assertion\AssertionComposite; +use Testo\Assert\State\Assertion\AssertionException; /** * Assertion utilities for numeric data type. * + * Numeric covers integers, floats and numeric strings; a numeric string is normalised to + * `int|float` (via `+ 0`) up front so every {@see NumericTrait} comparison runs against a + * real number instead of relying on PHP's string-to-number coercion at each call. + * * @internal * @psalm-internal Testo\Assert */ -final readonly class AssertNumeric implements IntType +final readonly class AssertNumeric implements NumericType { use NumericTrait; @@ -22,4 +28,21 @@ public function __construct( public int|float $value, private AssertionComposite $parent, ) {} + + /** + * Validate that the given value is numeric (int, float, or numeric string) and return an + * AssertNumeric instance. + * + * @param mixed $value The value to be asserted as numeric. + * + * @throws AssertionException when the value is not numeric. + */ + public static function validateAndCreate(mixed $value): self + { + \is_int($value) || \is_float($value) || (\is_string($value) && \is_numeric($value)) + or StaticState::typeFail('numeric', $value); + + $parent = StaticState::typeSuccess('numeric', $value); + return new self(\is_string($value) ? $value + 0 : $value, $parent); + } } diff --git a/plugin/assert/tests/Self/AssertArray.php b/plugin/assert/tests/Self/AssertArray.php index e81e60df..0e8c7718 100644 --- a/plugin/assert/tests/Self/AssertArray.php +++ b/plugin/assert/tests/Self/AssertArray.php @@ -131,6 +131,33 @@ public function isList(): void Assert::array([])->isList(); } + public function sameElementsAs(): void + { + Assert::array([1, 2, 3])->sameElementsAs([3, 2, 1]); + Assert::array([])->sameElementsAs([]); + // keys are discarded during canonicalization + Assert::array(['a' => 1, 'b' => 2])->sameElementsAs([2, 1]); + // nested arrays are canonicalized recursively + Assert::array([[3, 2], [1]])->sameElementsAs([[1], [2, 3]]); + // loose comparison, as with assertEqualsCanonicalizing + Assert::array([1, 2])->sameElementsAs(['2', '1']); + // accepts any iterable as the expected side + Assert::array([1, 2, 3])->sameElementsAs(new \ArrayIterator([3, 1, 2])); + } + + /** + * @param array $value + * @param array $expected + */ + #[DataSet([[1, 2, 3], [1, 2]], 'different size')] + #[DataSet([[1, 2, 3], [1, 2, 4]], 'different elements')] + public function sameElementsAsFails(array $value, array $expected): never + { + Expect::exception(AssertionException::class) + ->withMessageContaining('my wonderful message'); + Assert::array($value)->sameElementsAs($expected, 'my wonderful message'); + } + /** * @param array $value */ diff --git a/plugin/assert/tests/Self/AssertBlank.php b/plugin/assert/tests/Self/AssertBlank.php index 2f1ae765..0aaf4142 100644 --- a/plugin/assert/tests/Self/AssertBlank.php +++ b/plugin/assert/tests/Self/AssertBlank.php @@ -13,9 +13,11 @@ /** * @see Assert::blank() + * @see Assert::notBlank() */ #[Test] #[Covers(Assert::class, 'blank')] +#[Covers(Assert::class, 'notBlank')] final class AssertBlank { public function checkBlankData(): void @@ -35,4 +37,25 @@ public function checkNotBlankFails(mixed $value): never ->withMessageContaining('my wonderful message'); Assert::blank($value, 'my wonderful message'); } + + public function checkNotBlankData(): void + { + Assert::notBlank([1]); + Assert::notBlank('a'); + Assert::notBlank(new \ArrayIterator([1])); + // unlike empty(), these represent valid data and are considered non-blank + Assert::notBlank(0); + Assert::notBlank('0'); + Assert::notBlank(false); + } + + #[DataSet([[]], 'empty array')] + #[DataSet([''], 'empty string')] + #[DataSet([null], 'null')] + public function checkBlankFailsNotBlank(mixed $value): never + { + Expect::exception(AssertionException::class) + ->withMessageContaining('my wonderful message'); + Assert::notBlank($value, 'my wonderful message'); + } } diff --git a/plugin/assert/tests/Self/AssertNumeric.php b/plugin/assert/tests/Self/AssertNumeric.php index 34ae008a..84d1d474 100644 --- a/plugin/assert/tests/Self/AssertNumeric.php +++ b/plugin/assert/tests/Self/AssertNumeric.php @@ -7,6 +7,7 @@ use Testo\Assert; use Testo\Assert\Internal\Assertion\AssertFloat as AssertFloatImpl; use Testo\Assert\Internal\Assertion\AssertInt as AssertIntImpl; +use Testo\Assert\Internal\Assertion\AssertNumeric as AssertNumericImpl; use Testo\Assert\Internal\Assertion\Traits\NumericTrait; use Testo\Assert\State\Assertion\AssertionException; use Testo\Codecov\Covers; @@ -17,12 +18,15 @@ /** * @see Assert::int() * @see Assert::float() + * @see Assert::numeric() */ #[Test] #[Covers(Assert::class, 'int')] #[Covers(Assert::class, 'float')] +#[Covers(Assert::class, 'numeric')] #[Covers(AssertIntImpl::class)] #[Covers(AssertFloatImpl::class)] +#[Covers(AssertNumericImpl::class)] #[Covers(NumericTrait::class)] final class AssertNumeric { @@ -33,6 +37,28 @@ public function checkDataType(): void Assert::float(42.1); } + public function checkNumericDataType(): void + { + // numeric accepts integers, floats and numeric strings + Assert::numeric(42); + Assert::numeric(42.1); + Assert::numeric('42'); + Assert::numeric('42.1'); + // a numeric string is normalised to a real number for comparisons + Assert::numeric('42')->greaterThan(41)->lessThan(43); + } + + #[DataSet(['abc'], 'non-numeric string')] + #[DataSet([true], 'boolean')] + #[DataSet([null], 'null')] + #[DataSet([[1]], 'array')] + public function checkNumericFails(mixed $value): never + { + Expect::exception(AssertionException::class) + ->withMessageContaining('is numeric'); + Assert::numeric($value); + } + public function greaterThan(): void { // actual is greater than min threshold diff --git a/skills/testo-migrate-from-phpunit/references/phpunit-to-testo-map.md b/skills/testo-migrate-from-phpunit/references/phpunit-to-testo-map.md index 5b283517..a3009148 100644 --- a/skills/testo-migrate-from-phpunit/references/phpunit-to-testo-map.md +++ b/skills/testo-migrate-from-phpunit/references/phpunit-to-testo-map.md @@ -35,6 +35,10 @@ the assertion **argument order flips** (see the pitfalls), and discovery is attr | `$this->assertCount(3, $coll)` | `Assert::count($coll, 3)` — count goes second. | | `$this->assertContains($needle, $hay)` | `Assert::contains($hay, $needle)` — haystack first. | | `$this->assertInstanceOf(Foo::class, $o)` | `Assert::instanceOf($o, Foo::class)`. | +| `$this->assertGreaterThan($e, $a)` (+`GreaterThanOrEqual`/`LessThan`/`LessThanOrEqual`) | `Assert::numeric($a)->greaterThan($e)` — subject first, threshold in the matcher. | +| `$this->assertArrayHasKey($k, $a)` / `assertArrayNotHasKey` | `Assert::array($a)->hasKeys($k)` / `->doesNotHaveKeys($k)`. Variadic — no `$message` arg. | +| `$this->assertEqualsCanonicalizing($e, $a)` | `Assert::array($a)->sameElementsAs($e)` — order-insensitive, loose comparison. | +| `$this->assertEmpty($a)` / `assertNotEmpty($a)` | `Assert::blank($a)` / `Assert::notBlank($a)` **only when `$a` is an array** — `blank()` treats `false`/`0`/`'0'` as valid data, so for other types port by hand. | | `$this->expectException(X::class)` before Act | `Expect::exception(X::class)->withMessage(...)->withCode(...)` before Act. Method return type becomes `never`. | | `$this->expectExceptionMessageMatches('/.../')` | `withMessageContaining('substring')` if a literal substring suffices; otherwise catch and assert manually. No PCRE. | | `$this->markTestSkipped('reason')` | `throw new \Testo\Core\Exception\SkipTest('reason')` from the test body. | @@ -43,6 +47,8 @@ the assertion **argument order flips** (see the pitfalls), and discovery is attr | `$this->createMock(Foo::class)` | Testo core ships no mocking. Bring your own (Mockery, Prophecy) or — preferred — a hand-rolled fake. Keeping Mockery? Add `testo/bridge-mockery`: it verifies expectations and isolates mocks after every test (drops the `tearDown()` / `MockeryPHPUnitIntegration` boilerplate) and counts a fulfilled expectation as an assertion, so a mock-only test stays out of `Status::Risky`. **Never** mock `final` classes or enums. | | `assertThat($v, $constraint)` | No constraint objects. Decompose into concrete `Assert::*` calls. | | `@group slow` / `#[Group('slow')]` | `#[Group('slow')]` from **`Testo\Filter\Group`**. Not repeatable — merge: `#[Group('slow','db')]`. Class-level groups are inherited (union with the method's). Select `--group=slow`, exclude `--group=!slow`. | +| `#[Repeat($times, $threshold)]` (PHPUnit 13.3+) | `#[\Testo\Repeat(times: $times, maxFailures: $threshold - 1)]` — `failureThreshold` (aborting count) → `maxFailures` (tolerated count), off by one; default `1` → omitted. | +| `#[Retry($maxAttempts)]` (PHPUnit 13.3+) | `#[\Testo\Retry(maxAttempts: $maxAttempts)]`. | | `@requires ext` | Suite separation in `testo.php` via `SuiteConfig` + finder excludes. | | `phpunit.xml` | `testo.php` (a real PHP file returning `ApplicationConfig`). Generate it with `vendor/bin/testo init` (scans `tests/` for suite folders); hand-tune per `testo-configure`. | diff --git a/skills/testo-write-tests/SKILL.md b/skills/testo-write-tests/SKILL.md index 5be7ca30..4edf99dc 100644 --- a/skills/testo-write-tests/SKILL.md +++ b/skills/testo-write-tests/SKILL.md @@ -67,6 +67,7 @@ Assert::true($flag); Assert::false($flag); Assert::null($value); Assert::blank($value); // null, '', [], or 0-count +Assert::notBlank($value); // inverse of blank(); false/0/'0' count as non-blank Assert::contains($collection, $needle); Assert::count($collection, 3); Assert::instanceOf($object, MyClass::class); @@ -78,7 +79,9 @@ Typed chains (use when you want a fluent series of checks on one value): ```php Assert::string($s)->contains('foo')->notContains('bar'); Assert::int($n)->greaterThan(0)->lessThanOrEqual(100); -Assert::array($a)->hasKeys(['id', 'name'])->isList()->hasCount(3)->contains('x')->notContains('y'); +Assert::numeric($n)->between(1, 100); // int, float, or numeric string +Assert::array($a)->hasKeys('id', 'name')->isList()->hasCount(3)->contains('x')->notContains('y'); +Assert::array($a)->sameElementsAs([3, 2, 1]); // order-insensitive, like assertEqualsCanonicalizing Assert::object($o)->instanceOf(Foo::class)->hasProperty('id'); Assert::json($s)->isObject()->hasKeys(['data', 'meta'])->assertPath('$.data.id', 42); ```