Skip to content

Commit 3d92656

Browse files
committed
feat(bridge-rector): decompose Assert::iterable() chains and allInstanceOf() into PHPUnit
The iterable head becomes assertIsIterable and shares the iterable matchers with the array head: contains/notContains, allInstanceOf as assertContainsOnlyInstancesOf, allOf over the exact native types as assertContainsOnly*, and hasCount/sameSizeAs, which on the iterable head convert only for countable sides since PHPUnit throws for a Generator. Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 2384981 commit 3d92656

5 files changed

Lines changed: 131 additions & 10 deletions

File tree

‎bridge/rector/FEATURE_PARITY.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Conversion coverage across the three directions supported by `testo/bridge-recto
1515
|---|:---:|:---:|:---:|
1616
| **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; `assertNotTrue`/`assertNotFalse`→`Assert::notSame($x, true|false)`; the `PHPUnit\Framework\assert*()` functions convert too; also inside a class only (a test method / static data provider), a call elsewhere is left untouched* | 🟡 *(`expect()->toX()`, 8 matchers, single only)* |
1717
| **actual/expected argument swap** | ✅ | ✅ | ✅ |
18-
| **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`, array-subject `assertEmpty`/`assertNotEmpty`→`blank`/`notBlank` (a non-object subject → `Assert::true(empty($x))`), `assertStringStartsWith`/`EndsWith`→`Assert::string()->startsWith()`/`endsWith()`, `assertStringContainsString`/`NotContainsString`→`Assert::string()->contains()`/`notContains()`, `assertNotContains`→`Assert::iterable()->notContains()`, `assertObjectHasProperty`→`Assert::object()->hasProperty()`, `assertIsList`→`Assert::array()->isList()`, `assertContainsOnlyInt`/`String`/…→`Assert::iterable()->allOf('int')` and `assertContainsOnlyInstancesOf`→`allInstanceOf()`, `assertSameSize`→`Assert::iterable()->sameSizeAs()` (`Countable` sides), `assertJson`→`Assert::json()` (JSON equality stays: PHPUnit compares canonical re-encoded JSON), `assertIs<Type>`→the type head, and the predicate checks (`assertIsBool`, `assertIsCallable`, `assertIsNot*`, `assertFileExists`, `assertDirectoryExists`, …)→`Assert::true|false(\is_bool($x))`, also the file/directory permission checks (`\file_exists($f) && !\is_readable($f)`, side-effect-free path), `assertFinite`/`Infinite`/`Nan` (`int|float` subject), the resource checks over `\gettype()` (a closed resource counts), `assertNotInstanceOf`→`Assert::false($x instanceof Foo)` and `assertObjectNotHasProperty`→`Assert::false(\property_exists($o, $p))` (object subject). 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* |
18+
| **Fluent / typed chains** (`Assert::string()->…`, Pest `->not->`, `toBeGreaterThan`) | 🟡 *`TypedAssertChainRector` decomposes into separate `assert*` lines (incl. 1→N `hasKeys`, `between`, `isList`→`assertIsList`, `sameElementsAs`→`assertEqualsCanonicalizing`, the `iterable` head→`assertIsIterable`, `allInstanceOf`→`assertContainsOnlyInstancesOf`, `allOf('int')`→`assertContainsOnlyInt` for the exact native types, `sameSizeAs`→`assertSameSize` on countable sides); 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`, array-subject `assertEmpty`/`assertNotEmpty`→`blank`/`notBlank` (a non-object subject → `Assert::true(empty($x))`), `assertStringStartsWith`/`EndsWith`→`Assert::string()->startsWith()`/`endsWith()`, `assertStringContainsString`/`NotContainsString`→`Assert::string()->contains()`/`notContains()`, `assertNotContains`→`Assert::iterable()->notContains()`, `assertObjectHasProperty`→`Assert::object()->hasProperty()`, `assertIsList`→`Assert::array()->isList()`, `assertContainsOnlyInt`/`String`/…→`Assert::iterable()->allOf('int')` and `assertContainsOnlyInstancesOf`→`allInstanceOf()`, `assertSameSize`→`Assert::iterable()->sameSizeAs()` (`Countable` sides), `assertJson`→`Assert::json()` (JSON equality stays: PHPUnit compares canonical re-encoded JSON), `assertIs<Type>`→the type head, and the predicate checks (`assertIsBool`, `assertIsCallable`, `assertIsNot*`, `assertFileExists`, `assertDirectoryExists`, …)→`Assert::true|false(\is_bool($x))`, also the file/directory permission checks (`\file_exists($f) && !\is_readable($f)`, side-effect-free path), `assertFinite`/`Infinite`/`Nan` (`int|float` subject), the resource checks over `\gettype()` (a closed resource counts), `assertNotInstanceOf`→`Assert::false($x instanceof Foo)` and `assertObjectNotHasProperty`→`Assert::false(\property_exists($o, $p))` (object subject). 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* |
1919
| **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* |
2020
| **Exception message/code (fluent)** `withMessage/withCode` ↔ `expectExceptionMessage/Code` | ✅ *`ExpectExceptionToPhpUnitRector` expands one chain into several statements (`withMessageContaining`→`expectExceptionMessage`, `withCode`→`expectExceptionCode`, regex `withMessagePattern`→`expectExceptionMessageMatches`); the exact `withMessage` also maps to `expectExceptionMessage` and loosens into a substring check; two modifiers on the same PHPUnit call abort the chain* | ✅ *`ExpectExceptionToTestoRector` folds the sibling `expectExceptionMessage/Code` after `expectException` into the `->withMessageContaining()/->withCode()` chain (StmtsAware), across assignments that cannot throw — PHPUnit matches the message as a substring; any other statement ends the run* | 🟡 *`->throws(X, 'msg')`'s second arg folds to `->withMessageContaining('msg')`; Pest has no exception-code modifier to map* |
2121
| **Exception message by regex** (`expectExceptionMessageMatches`) | ✅ *`withMessagePattern`→`expectExceptionMessageMatches`* | ✅ *`ExpectExceptionToTestoRector` folds it into `->withMessagePattern()`* | ➖ |

‎bridge/rector/src/TestoToPhpunit/TODO.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,15 @@ directory but are **not** registered in `config/testo-to-phpunit.php`.
8484
`Assert::array()->isList()`→`assertIsList`, `Assert::array()->sameElementsAs()`→`assertEqualsCanonicalizing`,
8585
…) into separate `assert*` statements, expanding 1→N where needed. A non-variable subject (e.g.
8686
`Assert::array($log->all())->isList()`) is hoisted into a scope-safe `$value` local so it is
87-
evaluated once. Matchers with no faithful PHPUnit line (JSON path/structure, `every`, `sameSizeAs`,
88-
custom) leave the whole chain untouched rather than half-converting.
87+
evaluated once. The `iterable` head maps to `assertIsIterable`, and it shares the iterable matchers
88+
with `array`: `contains`/`notContains`, `allInstanceOf`→`assertContainsOnlyInstancesOf`,
89+
`allOf('int')`→`assertContainsOnlyInt` (likewise `array`/`bool`/`float`/`null`/`string` and the
90+
`integer`/`boolean`/`double` aliases `allOf()` accepts), `hasCount`→`assertCount` and
91+
`sameSizeAs`→`assertSameSize`. PHPUnit throws for a `Generator` where Testo counts it, so on the
92+
`iterable` head the count-based two convert only for a subject (and an expected side) known to be
93+
an array or a `Countable` iterable. Matchers with no faithful PHPUnit line (JSON path/structure,
94+
`every`, `allOf()` with a class name or a pseudo-type, `notEmpty` on the `iterable` head, custom)
95+
leave the whole chain untouched rather than half-converting.
8996
- **`RepeatRetryRector`** (registered) — converts **method-level** `#[\Testo\Repeat]` /
9097
`#[\Testo\Retry]` into PHPUnit's `#[Repeat]` / `#[Retry]` (available since PHPUnit 13.3). Testo's
9198
`maxFailures` (tolerated failures, default 0) maps to PHPUnit's `failureThreshold` (aborting failure

‎bridge/rector/src/TestoToPhpunit/TypedAssertChainRector.php‎

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
use PhpParser\Node\Expr\Variable;
1414
use PhpParser\Node\Identifier;
1515
use PhpParser\Node\Name;
16+
use PhpParser\Node\Scalar\String_;
1617
use PhpParser\Node\Stmt\Expression;
1718
use PhpParser\Node\VariadicPlaceholder;
1819
use PHPStan\Analyser\Scope;
20+
use PHPStan\Type\ObjectType;
1921
use Rector\NodeTypeResolver\Node\AttributeKey;
2022
use Rector\Rector\AbstractRector;
2123
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
@@ -49,9 +51,16 @@
4951
*
5052
* The local name (`value`, `value2`, ...) is chosen to not shadow a variable already in scope.
5153
*
54+
* The `array` and `iterable` heads share the iterable matchers: `contains`/`notContains`,
55+
* `allInstanceOf($c)` → `assertContainsOnlyInstancesOf($c, $value)`, `allOf('int')` →
56+
* `assertContainsOnlyInt($value)` for the literal native types whose `get_debug_type()` name is the
57+
* type itself, and the count-based `hasCount`/`sameSizeAs`. PHPUnit throws for a `Generator` where
58+
* Testo iterates it, so on the `iterable` head those two convert only for a subject (and for
59+
* `sameSizeAs`, an expected side) known to be an array or a `Countable` iterable.
60+
*
5261
* Conservative by design: if the head type or ANY matcher in the chain has no faithful PHPUnit
53-
* counterpart (JSON path/structure assertions, `every()`, `sameSizeAs()`, custom matchers), the
54-
* whole chain is left untouched rather than half-converted. Those remain a TODO.
62+
* counterpart (JSON path/structure assertions, `every()`, `allOf()` with a class or pseudo-type,
63+
* custom matchers), the whole chain is left untouched rather than half-converted. Those remain a TODO.
5564
*/
5665
#[TestRectorFixtures('TypedAssertChainRector')]
5766
final class TypedAssertChainRector extends AbstractRector
@@ -66,9 +75,28 @@ final class TypedAssertChainRector extends AbstractRector
6675
'int' => 'assertIsInt',
6776
'float' => 'assertIsFloat',
6877
'array' => 'assertIsArray',
78+
'iterable' => 'assertIsIterable',
6979
'object' => 'assertIsObject',
7080
];
7181

82+
/**
83+
* `allOf()` type name, as `allOf()` normalises it, => the PHPUnit assertion checking the same
84+
* native type. Only the types whose `get_debug_type()` spelling is the type itself.
85+
*
86+
* @var array<non-empty-string, non-empty-string>
87+
*/
88+
private const ALL_OF_TO_CONTAINS_ONLY = [
89+
'array' => 'assertContainsOnlyArray',
90+
'bool' => 'assertContainsOnlyBool',
91+
'boolean' => 'assertContainsOnlyBool',
92+
'float' => 'assertContainsOnlyFloat',
93+
'double' => 'assertContainsOnlyFloat',
94+
'int' => 'assertContainsOnlyInt',
95+
'integer' => 'assertContainsOnlyInt',
96+
'null' => 'assertContainsOnlyNull',
97+
'string' => 'assertContainsOnlyString',
98+
];
99+
72100
public function getRuleDefinition(): RuleDefinition
73101
{
74102
return new RuleDefinition(
@@ -143,6 +171,7 @@ public function refactor(Node $node): ?array
143171
# The subject is asserted in the head AND in every matcher. If it is anything other than a
144172
# plain variable (a method call, property fetch, ...) hoisting it into a local avoids
145173
# re-evaluating it — and re-running its side effects — once per emitted assertion.
174+
$countable = $type === 'array' || $this->isCountableIterable($headArg->value);
146175
$prefix = [];
147176
$subject = $headArg->value;
148177
if (!$subject instanceof Variable) {
@@ -155,7 +184,9 @@ public function refactor(Node $node): ?array
155184

156185
foreach (\array_reverse($links) as $link) {
157186
$matcher = $this->getName($link->name);
158-
$mapped = $matcher === null ? null : $this->mapMatcher($type, $matcher, $link->args, $subject, $useThis);
187+
$mapped = $matcher === null
188+
? null
189+
: $this->mapMatcher($type, $matcher, $link->args, $subject, $useThis, $countable);
159190
if ($mapped === null) {
160191
# Any unmapped matcher aborts the whole conversion — never half-convert.
161192
return null;
@@ -215,10 +246,13 @@ private function isThisAvailable(Expression $node): bool
215246
* @param non-empty-string $type
216247
* @param non-empty-string $matcher
217248
* @param array<int, Arg|VariadicPlaceholder> $args
249+
* @param bool $countable Whether the subject is an array or a `Countable` iterable, which PHPUnit's
250+
* count-based assertions read the way Testo does.
218251
* @return list<Expression>|null
219252
*/
220-
private function mapMatcher(string $type, string $matcher, array $args, Expr $value, bool $useThis): ?array
253+
private function mapMatcher(string $type, string $matcher, array $args, Expr $value, bool $useThis, bool $countable): ?array
221254
{
255+
$iterable = $type === 'array' || $type === 'iterable';
222256
$first = ($args[0] ?? null) instanceof Arg ? $args[0]->value : null;
223257

224258
# `assertX($needle, $value)` — the common "subject is the last argument" shape.
@@ -238,9 +272,14 @@ private function mapMatcher(string $type, string $matcher, array $args, Expr $va
238272
$type === 'string' && $matcher === 'startsWith' => $needleFirst('assertStringStartsWith'),
239273
$type === 'string' && $matcher === 'endsWith' => $needleFirst('assertStringEndsWith'),
240274

241-
$type === 'array' && $matcher === 'contains' => $needleFirst('assertContains'),
242-
$type === 'array' && $matcher === 'notContains' => $needleFirst('assertNotContains'),
243-
$type === 'array' && $matcher === 'hasCount' => $needleFirst('assertCount'),
275+
$iterable && $matcher === 'contains' => $needleFirst('assertContains'),
276+
$iterable && $matcher === 'notContains' => $needleFirst('assertNotContains'),
277+
$iterable && $matcher === 'allInstanceOf' => $needleFirst('assertContainsOnlyInstancesOf'),
278+
$iterable && $matcher === 'allOf' => $this->allOf($first, $value, $useThis),
279+
# PHPUnit throws for a `Generator` where Testo iterates it: only a countable subject converts.
280+
$iterable && $countable && $matcher === 'hasCount' => $needleFirst('assertCount'),
281+
$iterable && $countable && $matcher === 'sameSizeAs' && $first !== null && $this->isCountableIterable($first)
282+
=> $needleFirst('assertSameSize'),
244283
$type === 'array' && $matcher === 'notEmpty' => [$this->assertStmt('assertNotEmpty', [$this->arg($value)], $useThis)],
245284
$type === 'array' && $matcher === 'isList' => [$this->assertStmt('assertIsList', [$this->arg($value)], $useThis)],
246285
$type === 'array' && $matcher === 'hasKeys' => $this->keys('assertArrayHasKey', $args, $value, $useThis),
@@ -274,6 +313,26 @@ private function between(array $args, Expr $value, bool $useThis): ?array
274313
];
275314
}
276315

316+
/**
317+
* `allOf('int')` → `assertContainsOnlyInt($value)`, for a literal type name PHPUnit checks the same way.
318+
*
319+
* @return list<Expression>|null
320+
*/
321+
private function allOf(?Expr $type, Expr $value, bool $useThis): ?array
322+
{
323+
$assert = $type instanceof String_ ? self::ALL_OF_TO_CONTAINS_ONLY[\strtolower($type->value)] ?? null : null;
324+
325+
return $assert === null ? null : [$this->assertStmt($assert, [$this->arg($value)], $useThis)];
326+
}
327+
328+
private function isCountableIterable(Expr $expr): bool
329+
{
330+
$type = $this->getType($expr);
331+
332+
return $type->isArray()->yes()
333+
|| ($type->isIterable()->yes() && (new ObjectType(\Countable::class))->isSuperTypeOf($type)->yes());
334+
}
335+
277336
/**
278337
* Variadic key matcher → one `assertArrayHasKey`/`assertArrayNotHasKey` per key.
279338
*
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
class IterableChainTest
4+
{
5+
public function test(\ArrayObject $bag, array $expected)
6+
{
7+
\Testo\Assert::iterable($items)->contains('x')->notContains('y')->allInstanceOf(\Countable::class);
8+
\Testo\Assert::iterable($ids)->allOf('int');
9+
\Testo\Assert::iterable($flags)->allOf('boolean')->allOf('Bool');
10+
\Testo\Assert::iterable($bag)->hasCount(2)->sameSizeAs($expected);
11+
\Testo\Assert::array($rows)->allOf('array')->allInstanceOf(\stdClass::class)->sameSizeAs($expected);
12+
}
13+
}
14+
-----
15+
<?php
16+
17+
class IterableChainTest
18+
{
19+
public function test(\ArrayObject $bag, array $expected)
20+
{
21+
$this->assertIsIterable($items);
22+
$this->assertContains('x', $items);
23+
$this->assertNotContains('y', $items);
24+
$this->assertContainsOnlyInstancesOf(\Countable::class, $items);
25+
$this->assertIsIterable($ids);
26+
$this->assertContainsOnlyInt($ids);
27+
$this->assertIsIterable($flags);
28+
$this->assertContainsOnlyBool($flags);
29+
$this->assertContainsOnlyBool($flags);
30+
$this->assertIsIterable($bag);
31+
$this->assertCount(2, $bag);
32+
$this->assertSameSize($expected, $bag);
33+
$this->assertIsArray($rows);
34+
$this->assertContainsOnlyArray($rows);
35+
$this->assertContainsOnlyInstancesOf(\stdClass::class, $rows);
36+
$this->assertSameSize($expected, $rows);
37+
}
38+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
class IterableUnmappedTest
4+
{
5+
// PHPUnit throws for a Generator where Testo counts it; allOf() with a class or pseudo-type has no
6+
// assertContainsOnly* counterpart.
7+
public function test(iterable $any, \Traversable $stream, array $expected)
8+
{
9+
\Testo\Assert::iterable($any)->hasCount(2);
10+
\Testo\Assert::iterable($stream)->sameSizeAs($expected);
11+
\Testo\Assert::array($rows)->sameSizeAs($any);
12+
\Testo\Assert::iterable($items)->allOf(\stdClass::class);
13+
\Testo\Assert::iterable($items)->allOf('object');
14+
\Testo\Assert::iterable($items)->allOf($type);
15+
\Testo\Assert::iterable($items)->every('is_int');
16+
}
17+
}

0 commit comments

Comments
 (0)