From bc733bc4e3d2e11871717e25f90991a937ee4c96 Mon Sep 17 00:00:00 2001 From: Ichi Date: Thu, 15 Oct 2026 19:52:54 +0500 Subject: [PATCH 1/2] fix(rector): preserve string assertion call contracts Map PHPUnit string parameter names and retain caller weak/strict coercion, message validation, evaluation order and void results through typed closures. Keep safe standalone calls concise and unsupported bindings available for manual migration. Execute converted weak and strict fixtures through the CLI in child processes and check each test's outcome and assertion count. Refs #390 Assisted-By: GPT-6 --- bridge/rector/FEATURE_PARITY.md | 3 + bridge/rector/README.md | 8 + .../AssertCallToTestoRector.php | 7 + .../unsupported_named_unchanged.php.inc | 11 + bridge/rector/src/PhpunitToTesto/TODO.md | 7 +- .../TypedAssertCallToTestoRector.php | 147 +++++++++--- .../contains_list_property.php.inc | 8 +- .../regular_expression.php.inc | 16 +- .../regular_expression_function_call.php.inc | 8 +- .../runtime_strict.php.inc | 63 +++++ .../runtime_weak.php.inc | 227 ++++++++++++++++++ .../string_not_starts_ends_with.php.inc | 8 +- .../type_checks.php.inc | 8 +- .../unsupported_named_unchanged.php.inc | 14 ++ .../src/Testing/Internal/RectorRunner.php | 14 +- .../Feature/Fixture/ConvertedRuntime.php | 73 ++++++ .../Feature/TypedAssertionRuntimeTest.php | 62 +++++ bridge/rector/tests/suites.php | 5 + .../references/phpunit-to-testo-map.md | 6 + 19 files changed, 643 insertions(+), 52 deletions(-) create mode 100644 bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector/unsupported_named_unchanged.php.inc create mode 100644 bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/runtime_strict.php.inc create mode 100644 bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/runtime_weak.php.inc create mode 100644 bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/unsupported_named_unchanged.php.inc create mode 100644 bridge/rector/tests/Feature/Fixture/ConvertedRuntime.php create mode 100644 bridge/rector/tests/Feature/TypedAssertionRuntimeTest.php diff --git a/bridge/rector/FEATURE_PARITY.md b/bridge/rector/FEATURE_PARITY.md index ee441d48..ce108d8b 100644 --- a/bridge/rector/FEATURE_PARITY.md +++ b/bridge/rector/FEATURE_PARITY.md @@ -158,3 +158,6 @@ both reverse directions produce it — a `markTestSkipped('literal')` opening a (`MarkTestSkippedToSkipAttributeRector`) and Pest's `->skip('reason')` modifier. What stays a `SkipTest` throw is exactly what cannot be declared: a guarded call, one deeper in the body, or a message no attribute argument can hold. + +String assertions preserve named binding, weak/strict coercion, argument evaluation order and void +returns through a typed inline closure when needed. Other named assertion shapes remain manual. diff --git a/bridge/rector/README.md b/bridge/rector/README.md index 2062365a..054c3397 100644 --- a/bridge/rector/README.md +++ b/bridge/rector/README.md @@ -72,6 +72,14 @@ Testo's comparison assertions are `(actual, expected)`; PHPUnit's are `(expected Both `AssertCall*` rules swap the first two arguments accordingly — getting this wrong would silently invert every comparison, so it is covered by fixtures. +### String assertion contracts + +String assertion calls with effectful or potentially coercible arguments, or a used result, use an inline +typed closure with PHPUnit's parameter names. It keeps source evaluation order, weak/strict string +coercion, message validation and the original `void` return. Simple standalone calls with native string +types keep the shorter fluent form, normalising supported named arguments positionally. Other named +assertion shapes remain for manual migration. + ## Why Testo → PHPUnit Testo is self-hosted: the engine that discovers and runs tests is the same code Infection diff --git a/bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector.php b/bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector.php index 5f1ed5e8..1bcd29b7 100644 --- a/bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector.php +++ b/bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector.php @@ -113,6 +113,13 @@ public function refactor(Node $node): ?Node return null; } + # Keep unsupported argument bindings visible for manual migration. + foreach ($node->args as $argument) { + if (!$argument instanceof Arg || $argument->unpack || $argument->name !== null) { + return null; + } + } + if (isset(self::NOT_BOOL[$method])) { return $this->notBool(self::NOT_BOOL[$method], $node->args); } diff --git a/bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector/unsupported_named_unchanged.php.inc b/bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector/unsupported_named_unchanged.php.inc new file mode 100644 index 00000000..75255939 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector/unsupported_named_unchanged.php.inc @@ -0,0 +1,11 @@ +assertSame(expected: 1, actual: 1); + $this->assertTrue(actual: true); + $this->assertSame(...$arguments); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/TODO.md b/bridge/rector/src/PhpunitToTesto/TODO.md index 5fb153de..11bbb924 100644 --- a/bridge/rector/src/PhpunitToTesto/TODO.md +++ b/bridge/rector/src/PhpunitToTesto/TODO.md @@ -155,8 +155,11 @@ Mocks are out of this set: they convert through `phpunit-to-double` or `phpunit- error check, so a match that fails at runtime (backtrack or JIT stack limit) failed `assertRegExp` and passed `assertNotRegExp`; `matchesRegex()`/`notMatchesRegex()` throw `\InvalidArgumentException` there, as PHPUnit 10+ throws a framework exception. The string matchers - also need a string subject: outside `strict_types` PHPUnit's `string` parameter coerces an int, - float or `Stringable`, which `Assert::string()` rejects. + preserve the caller's string boundary through an inline typed void closure when native string types, + simple expressions and an unused result do not justify the shorter chain. Safe named string + arguments are normalised positionally; other named strings bind to + PHPUnit's source parameter names; argument evaluation order, Stringable coercion and message validation + stay at the call boundary. Other named assertion shapes remain manual. **Whitespace residual:** PHPUnit runs `preg_replace('/\s+/u', ' ')` and `trim()`, while `ignoringWhitespace(lineBreaks: true)` also counts the `\h`/`\v` characters outside `\s` (U+180E) diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector.php b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector.php index c419e821..27136bda 100644 --- a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector.php +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector.php @@ -11,6 +11,7 @@ use PhpParser\Node\Expr\BinaryOp\Concat; use PhpParser\Node\Expr\BooleanNot; use PhpParser\Node\Expr\ClassConstFetch; +use PhpParser\Node\Expr\Closure; use PhpParser\Node\Expr\ConstFetch; use PhpParser\Node\Expr\Empty_; use PhpParser\Node\Expr\FuncCall; @@ -21,8 +22,11 @@ use PhpParser\Node\Identifier; use PhpParser\Node\Name; use PhpParser\Node\Name\FullyQualified; +use PhpParser\Node\Param; use PhpParser\Node\Scalar\MagicConst; use PhpParser\Node\Scalar\String_; +use PhpParser\Node\Stmt\Expression; +use PhpParser\NodeFinder; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Type\FloatType; @@ -39,8 +43,10 @@ * Rewrites the PHPUnit assertions that map onto a Testo **typed head + matcher** chain rather than a * flat facade call — the shape {@see AssertCallToTestoRector} deliberately does not handle. * - * The subject moves from an argument position to the head argument (still evaluated exactly once, - * so no hoisting is needed) and the remaining argument becomes the matcher argument: + * The subject moves from an argument position to the head argument. For string assertions, the + * short form requires a standalone call with simple expressions and native string types. Otherwise a typed + * void closure keeps source parameter names, evaluation order and weak/strict coercion, including + * the message. PHPDoc-only string types do not justify bypassing the parameter boundary. * * - $this->assertGreaterThan($e, $a) → \Testo\Assert::numeric($a)->greaterThan($e) * - $this->assertGreaterThanOrEqual(...) → …->greaterThanOrEqual(…) @@ -114,7 +120,8 @@ * either, so one on `assertJson` is dropped as well. * * Recognises the same call spellings as {@see AssertCallToTestoRector}, `PHPUnit\Framework\assert*()` - * functions included, and likewise only inside a class. + * functions included, and likewise only inside a class. Unsupported named argument shapes outside + * the string mappings remain for manual migration. */ #[TestRectorFixtures('TypedAssertCallToTestoRector')] final class TypedAssertCallToTestoRector extends AbstractRector @@ -142,22 +149,6 @@ final class TypedAssertCallToTestoRector extends AbstractRector 'assertArrayNotHasKey' => 'doesNotHaveKeys', ]; - /** - * String assertions with a comparison mode → `Assert::string($subject)->()->()`. - * `$message` is preserved. PHPUnit's whitespace mode turns every run of whitespace, line breaks - * included, into one space and trims, which is `ignoringWhitespace(lineBreaks: true)`. - * - * @var array - */ - private const STRING_MODIFIED = [ - 'assertStringContainsStringIgnoringCase' => ['ignoringCase', 'contains'], - 'assertStringNotContainsStringIgnoringCase' => ['ignoringCase', 'notContains'], - 'assertStringContainsStringIgnoringLineEndings' => ['ignoringLineEndings', 'contains'], - 'assertStringEqualsStringIgnoringLineEndings' => ['ignoringLineEndings', 'same'], - 'assertStringEqualsStringIgnoringWhitespace' => ['ignoringWhitespace', 'same'], - 'assertStringNotEqualsStringIgnoringWhitespace' => ['ignoringWhitespace', 'notSame'], - ]; - /** * Case-insensitive equality → `Assert::string($actual)->ignoringCase()->same|notSame($expected)`, * only when both sides are strings: PHPUnit also compares other scalars and arrays, recursively. @@ -315,23 +306,22 @@ public function refactor(Node $node): ?Node return null; } + $string = $this->stringAssertion($method); + if ($string !== null) { + return $this->stringChain($node, ...$string); + } + + # Other mappings do not yet model PHPUnit's named parameter contracts. + foreach ($node->args as $argument) { + if (!$argument instanceof Arg || $argument->unpack || $argument->name !== null) { + 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 === 'assertStringStartsWith' => $this->typedChain('string', 'startsWith', $node->args, keepMessage: true), - $method === 'assertStringEndsWith' => $this->typedChain('string', 'endsWith', $node->args, keepMessage: true), - $method === 'assertStringStartsNotWith' => $this->typedChain('string', 'notStartsWith', $node->args, keepMessage: true), - $method === 'assertStringEndsNotWith' => $this->typedChain('string', 'notEndsWith', $node->args, keepMessage: true), - $method === 'assertStringContainsString' => $this->typedChain('string', 'contains', $node->args, keepMessage: true), - $method === 'assertStringNotContainsString' => $this->typedChain('string', 'notContains', $node->args, keepMessage: true), - isset(self::STRING_MODIFIED[$method]) => $this->typedChain( - 'string', - self::STRING_MODIFIED[$method][1], - $node->args, - keepMessage: true, - modifier: self::STRING_MODIFIED[$method][0], - ), isset(self::EQUALS_IGNORING_CASE[$method]) && $this->areStrings($node->args) => $this->typedChain( 'string', self::EQUALS_IGNORING_CASE[$method], @@ -339,10 +329,6 @@ public function refactor(Node $node): ?Node keepMessage: true, modifier: 'ignoringCase', ), - $method === 'assertMatchesRegularExpression', $method === 'assertRegExp' - => $this->typedChain('string', 'matchesRegex', $node->args, keepMessage: true), - $method === 'assertDoesNotMatchRegularExpression', $method === 'assertNotRegExp' - => $this->typedChain('string', 'notMatchesRegex', $node->args, keepMessage: true), $method === 'assertNotContains' => $this->typedChain('iterable', 'notContains', $node->args, keepMessage: true), $method === 'assertObjectHasProperty' => $this->typedChain('object', 'hasProperty', $node->args, keepMessage: true), $method === 'assertIsList' => $this->subjectChain('array', 'isList', [], $node->args), @@ -363,6 +349,95 @@ public function refactor(Node $node): ?Node }; } + /** + * @return array{non-empty-string, non-empty-string, non-empty-string, non-empty-string|null}|null + */ + private function stringAssertion(string $method): ?array + { + return match ($method) { + 'assertStringStartsWith' => ['prefix', 'string', 'startsWith', null], + 'assertStringEndsWith' => ['suffix', 'string', 'endsWith', null], + 'assertStringStartsNotWith' => ['prefix', 'string', 'notStartsWith', null], + 'assertStringEndsNotWith' => ['suffix', 'string', 'notEndsWith', null], + 'assertStringContainsString' => ['needle', 'haystack', 'contains', null], + 'assertStringNotContainsString' => ['needle', 'haystack', 'notContains', null], + 'assertStringContainsStringIgnoringCase' => ['needle', 'haystack', 'contains', 'ignoringCase'], + 'assertStringNotContainsStringIgnoringCase' => ['needle', 'haystack', 'notContains', 'ignoringCase'], + 'assertStringContainsStringIgnoringLineEndings' => ['needle', 'haystack', 'contains', 'ignoringLineEndings'], + 'assertStringEqualsStringIgnoringLineEndings' => ['expected', 'actual', 'same', 'ignoringLineEndings'], + 'assertStringEqualsStringIgnoringWhitespace' => ['expected', 'actual', 'same', 'ignoringWhitespace'], + 'assertStringNotEqualsStringIgnoringWhitespace' => ['expected', 'actual', 'notSame', 'ignoringWhitespace'], + 'assertMatchesRegularExpression', 'assertRegExp' => ['pattern', 'string', 'matchesRegex', null], + 'assertDoesNotMatchRegularExpression', 'assertNotRegExp' => ['pattern', 'string', 'notMatchesRegex', null], + default => null, + }; + } + + /** + * Keep the native string parameter boundary and lexical evaluation order when a direct + * head/matcher split would change them. Named arguments bind to the source parameter names. + * + * @param MethodCall|StaticCall|FuncCall $node + */ + private function stringChain(Node $node, string $first, string $second, string $matcher, ?string $modifier): ?Expr + { + $args = $node->args; + $bound = []; + $named = false; + $parameters = [$first, $second, 'message']; + foreach ($args as $index => $argument) { + if (!$argument instanceof Arg || $argument->unpack || $argument->byRef + || ($named && $argument->name === null) + ) { + return null; + } + $named = $argument->name !== null; + $name = $argument->name?->toString() ?? ($parameters[$index] ?? null); + if ($name === null || !\in_array($name, $parameters, true) || isset($bound[$name])) { + return null; + } + $bound[$name] = $argument; + } + if (!isset($bound[$first], $bound[$second])) { + return null; + } + + $direct = (new NodeFinder())->findFirst( + $this->getFile()->getNewStmts(), + static fn(Node $statement): bool => $statement instanceof Expression && $statement->expr === $node, + ) !== null; + foreach ($args as $argument) { + $direct = $direct + && ($argument->value instanceof String_ || ($argument->value instanceof Variable && \is_string($argument->value->name))) + && $this->getNativeType($argument->value)->isString()->yes(); + } + if ($direct) { + $ordered = []; + foreach ($parameters as $name) { + if (isset($bound[$name])) { + $ordered[] = new Arg($bound[$name]->value); + } + } + return $this->typedChain('string', $matcher, $ordered, keepMessage: true, modifier: $modifier); + } + + $params = []; + $inner = []; + foreach ($parameters as $index => $name) { + $variable = new Variable($name); + $params[] = new Param($variable, default: $index === 2 ? new String_('') : null, type: new Identifier('string')); + $inner[] = new Arg($variable); + } + $chain = $this->typedChain('string', $matcher, $inner, keepMessage: true, modifier: $modifier); + \assert($chain instanceof MethodCall); + return new FuncCall(new Closure([ + 'static' => true, + 'params' => $params, + 'returnType' => new Identifier('void'), + 'stmts' => [new Expression($chain)], + ]), $args); + } + /** * `assertIsString($subject)` → `Assert::string($subject)`. A type head takes no message, so a call * that carries one falls back to the predicate form, which keeps it. diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/contains_list_property.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/contains_list_property.php.inc index 84068281..23d55ca1 100644 --- a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/contains_list_property.php.inc +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/contains_list_property.php.inc @@ -20,8 +20,12 @@ class ContainsListPropertyTest { public function test() { - \Testo\Assert::string($haystack)->contains('needle'); - \Testo\Assert::string($output)->notContains('error', 'no errors'); + (static function (string $needle, string $haystack, string $message = ''): void { + \Testo\Assert::string($haystack)->contains($needle, $message); + })('needle', $haystack); + (static function (string $needle, string $haystack, string $message = ''): void { + \Testo\Assert::string($haystack)->notContains($needle, $message); + })('error', $output, 'no errors'); \Testo\Assert::iterable($items)->notContains($removed); \Testo\Assert::iterable($letters)->notContains('x', 'x is gone'); \Testo\Assert::object($entity)->hasProperty('id', 'has an id'); diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/regular_expression.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/regular_expression.php.inc index 7a16a552..6f4a726f 100644 --- a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/regular_expression.php.inc +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/regular_expression.php.inc @@ -17,9 +17,17 @@ class RegularExpressionAssertTest { public function test() { - \Testo\Assert::string($date)->matchesRegex('/^\d{4}-\d{2}-\d{2}$/'); - \Testo\Assert::string($date)->notMatchesRegex('/\s$/', 'no trailing space'); - \Testo\Assert::string($name)->matchesRegex('/^a/i', 'starts with a'); - \Testo\Assert::string($name)->notMatchesRegex('/^b/'); + (static function (string $pattern, string $string, string $message = ''): void { + \Testo\Assert::string($string)->matchesRegex($pattern, $message); + })('/^\d{4}-\d{2}-\d{2}$/', $date); + (static function (string $pattern, string $string, string $message = ''): void { + \Testo\Assert::string($string)->notMatchesRegex($pattern, $message); + })('/\s$/', $date, 'no trailing space'); + (static function (string $pattern, string $string, string $message = ''): void { + \Testo\Assert::string($string)->matchesRegex($pattern, $message); + })('/^a/i', $name, 'starts with a'); + (static function (string $pattern, string $string, string $message = ''): void { + \Testo\Assert::string($string)->notMatchesRegex($pattern, $message); + })('/^b/', $name); } } diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/regular_expression_function_call.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/regular_expression_function_call.php.inc index 8fc0cf39..d45c6828 100644 --- a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/regular_expression_function_call.php.inc +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/regular_expression_function_call.php.inc @@ -25,7 +25,11 @@ class RegularExpressionFunctionCallTest { public function test() { - \Testo\Assert::string($id)->matchesRegex('/^id-\d+$/', 'id format'); - \Testo\Assert::string($id)->notMatchesRegex('/\s/'); + (static function (string $pattern, string $string, string $message = ''): void { + \Testo\Assert::string($string)->matchesRegex($pattern, $message); + })('/^id-\d+$/', $id, 'id format'); + (static function (string $pattern, string $string, string $message = ''): void { + \Testo\Assert::string($string)->notMatchesRegex($pattern, $message); + })('/\s/', $id); } } diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/runtime_strict.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/runtime_strict.php.inc new file mode 100644 index 00000000..0df8c9ff --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/runtime_strict.php.inc @@ -0,0 +1,63 @@ +assertStringContainsString(haystack: 'xyz', needle: 'x'); + } + + public function testIntegerMustError(): void + { + $this->assertStringContainsString('2', 123); + } + + public function testStringableMustError(): void + { + $this->assertStringContainsString('x', new class implements \Stringable { + public function __toString(): string { return 'xyz'; } + }); + } + + public function testInvalidMessageMustError(): void + { + $this->assertStringContainsString('x', 'xyz', message: 123); + } +} +----- +contains('x'); + } + + public function testIntegerMustError(): void + { + (static function (string $needle, string $haystack, string $message = ''): void { + \Testo\Assert::string($haystack)->contains($needle, $message); + })('2', 123); + } + + public function testStringableMustError(): void + { + (static function (string $needle, string $haystack, string $message = ''): void { + \Testo\Assert::string($haystack)->contains($needle, $message); + })('x', new class implements \Stringable { + public function __toString(): string { return 'xyz'; } + }); + } + + public function testInvalidMessageMustError(): void + { + (static function (string $needle, string $haystack, string $message = ''): void { + \Testo\Assert::string($haystack)->contains($needle, $message); + })('x', 'xyz', message: 123); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/runtime_weak.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/runtime_weak.php.inc new file mode 100644 index 00000000..7f539174 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/runtime_weak.php.inc @@ -0,0 +1,227 @@ +assertStringContainsString(needle: 'x', haystack: 'xyz'); + $needle = 'x'; + $haystack = 'xyz'; + $this->assertStringContainsString(haystack: $haystack, needle: $needle); + } + + public function testNamedReversed(): void + { + $this->trace = []; + $this->assertStringContainsString(haystack: $this->mark('haystack', 'xyz'), needle: $this->mark('needle', 'x'), message: $this->mark('message', 'named')); + \Testo\Assert::same($this->trace, ['haystack', 'needle', 'message']); + } + + public function testWeakScalar(): void + { + $this->assertStringContainsString('2', 123); + } + + public function testPhpDocCannotReplaceNativeType(): void + { + $this->annotatedHaystack(123); + } + + public function testWeakStringable(): void + { + $this->trace = []; + $this->assertStringContainsString('x', new class(fn() => $this->mark('cast', 'xyz')) implements \Stringable { + public function __construct(private \Closure $value) {} + public function __toString(): string { return ($this->value)(); } + }); + \Testo\Assert::same($this->trace, ['cast']); + } + + public function testPositionalAndNamedMessage(): void + { + $this->assertStringContainsString('x', 'xyz', message: 'present'); + $this->assertStringStartsWith(message: 'context', prefix: 'x', string: 'xyz'); + } + + public function testFunctionNamedReversed(): void + { + \PHPUnit\Framework\assertStringContainsString(haystack: 'xyz', needle: 'x'); + } + + public function testSelfAndStatic(): void + { + self::assertStringContainsString('2', 123); + static::assertStringContainsString(haystack: 'xyz', needle: 'x'); + } + + public function testWeakMessageScalar(): void + { + $this->assertStringContainsString('x', 'xyz', message: 123); + } + + public function testPositionalEvaluationOrder(): void + { + $this->trace = []; + $this->assertStringContainsString($this->mark('needle', 'x'), $this->mark('haystack', 'xyz'), $this->mark('message', 'present')); + \Testo\Assert::same($this->trace, ['needle', 'haystack', 'message']); + } + + public function testNecessaryFailure(): void + { + $this->assertStringContainsString('missing', 'xyz'); + } + + public function testReturnRemainsNull(): void + { + \Testo\Assert::same($this->assertStringContainsString(needle: 'x', haystack: 'xyz'), null); + } + + public function testPositionalReturnRemainsNull(): void + { + \Testo\Assert::same($this->assertStringContainsString('x', 'xyz'), null); + \Testo\Assert::same($this->assertionReturn(), null); + } + + private function mark(string $label, string $value): string + { + $this->trace[] = $label; + return $value; + } + + /** @param string $haystack */ + private function annotatedHaystack($haystack): void + { + $this->assertStringContainsString('2', $haystack); + } + + private function assertionReturn(): mixed + { + return $this->assertStringContainsString('x', 'xyz'); + } +} +----- +contains('x'); + $needle = 'x'; + $haystack = 'xyz'; + \Testo\Assert::string($haystack)->contains($needle); + } + + public function testNamedReversed(): void + { + $this->trace = []; + (static function (string $needle, string $haystack, string $message = ''): void { + \Testo\Assert::string($haystack)->contains($needle, $message); + })(haystack: $this->mark('haystack', 'xyz'), needle: $this->mark('needle', 'x'), message: $this->mark('message', 'named')); + \Testo\Assert::same($this->trace, ['haystack', 'needle', 'message']); + } + + public function testWeakScalar(): void + { + (static function (string $needle, string $haystack, string $message = ''): void { + \Testo\Assert::string($haystack)->contains($needle, $message); + })('2', 123); + } + + public function testPhpDocCannotReplaceNativeType(): void + { + $this->annotatedHaystack(123); + } + + public function testWeakStringable(): void + { + $this->trace = []; + (static function (string $needle, string $haystack, string $message = ''): void { + \Testo\Assert::string($haystack)->contains($needle, $message); + })('x', new class(fn() => $this->mark('cast', 'xyz')) implements \Stringable { + public function __construct(private \Closure $value) {} + public function __toString(): string { return ($this->value)(); } + }); + \Testo\Assert::same($this->trace, ['cast']); + } + + public function testPositionalAndNamedMessage(): void + { + \Testo\Assert::string('xyz')->contains('x', 'present'); + \Testo\Assert::string('xyz')->startsWith('x', 'context'); + } + + public function testFunctionNamedReversed(): void + { + \Testo\Assert::string('xyz')->contains('x'); + } + + public function testSelfAndStatic(): void + { + (static function (string $needle, string $haystack, string $message = ''): void { + \Testo\Assert::string($haystack)->contains($needle, $message); + })('2', 123); + \Testo\Assert::string('xyz')->contains('x'); + } + + public function testWeakMessageScalar(): void + { + (static function (string $needle, string $haystack, string $message = ''): void { + \Testo\Assert::string($haystack)->contains($needle, $message); + })('x', 'xyz', message: 123); + } + + public function testPositionalEvaluationOrder(): void + { + $this->trace = []; + (static function (string $needle, string $haystack, string $message = ''): void { + \Testo\Assert::string($haystack)->contains($needle, $message); + })($this->mark('needle', 'x'), $this->mark('haystack', 'xyz'), $this->mark('message', 'present')); + \Testo\Assert::same($this->trace, ['needle', 'haystack', 'message']); + } + + public function testNecessaryFailure(): void + { + \Testo\Assert::string('xyz')->contains('missing'); + } + + public function testReturnRemainsNull(): void + { + \Testo\Assert::same((static function (string $needle, string $haystack, string $message = ''): void { + \Testo\Assert::string($haystack)->contains($needle, $message); + })(needle: 'x', haystack: 'xyz'), null); + } + + public function testPositionalReturnRemainsNull(): void + { + \Testo\Assert::same((static function (string $needle, string $haystack, string $message = ''): void { + \Testo\Assert::string($haystack)->contains($needle, $message); + })('x', 'xyz'), null); + \Testo\Assert::same($this->assertionReturn(), null); + } + + private function mark(string $label, string $value): string + { + $this->trace[] = $label; + return $value; + } + + /** @param string $haystack */ + private function annotatedHaystack($haystack): void + { + (static function (string $needle, string $haystack, string $message = ''): void { + \Testo\Assert::string($haystack)->contains($needle, $message); + })('2', $haystack); + } + + private function assertionReturn(): mixed + { + return (static function (string $needle, string $haystack, string $message = ''): void { + \Testo\Assert::string($haystack)->contains($needle, $message); + })('x', 'xyz'); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/string_not_starts_ends_with.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/string_not_starts_ends_with.php.inc index 5ea3a83b..89b29e31 100644 --- a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/string_not_starts_ends_with.php.inc +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/string_not_starts_ends_with.php.inc @@ -15,7 +15,11 @@ class StringNotStartsEndsWithTest { public function test() { - \Testo\Assert::string($path)->notStartsWith('/tmp'); - \Testo\Assert::string($path)->notEndsWith('.md', 'not markdown'); + (static function (string $prefix, string $string, string $message = ''): void { + \Testo\Assert::string($string)->notStartsWith($prefix, $message); + })('/tmp', $path); + (static function (string $suffix, string $string, string $message = ''): void { + \Testo\Assert::string($string)->notEndsWith($suffix, $message); + })('.md', $path, 'not markdown'); } } diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/type_checks.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/type_checks.php.inc index 5508cb3d..6f990ec1 100644 --- a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/type_checks.php.inc +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/type_checks.php.inc @@ -37,7 +37,11 @@ class TypeChecksTest \Testo\Assert::false(\is_int($value), 'not an int'); \Testo\Assert::true(\file_exists($path)); \Testo\Assert::false(\is_dir($dir)); - \Testo\Assert::string($message)->startsWith('Name must'); - \Testo\Assert::string($file)->endsWith('.json', 'suffix'); + (static function (string $prefix, string $string, string $message = ''): void { + \Testo\Assert::string($string)->startsWith($prefix, $message); + })('Name must', $message); + (static function (string $suffix, string $string, string $message = ''): void { + \Testo\Assert::string($string)->endsWith($suffix, $message); + })('.json', $file, 'suffix'); } } diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/unsupported_named_unchanged.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/unsupported_named_unchanged.php.inc new file mode 100644 index 00000000..f0680333 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/unsupported_named_unchanged.php.inc @@ -0,0 +1,14 @@ +assertStringContainsString(wrong: 'x', haystack: 'xyz'); + $this->assertStringContainsString(needle: 'x', haystack: 'xyz', wrong: 'context'); + $this->assertStringContainsString(...$arguments); + $this->assertStringContainsString(...); + $this->assertIsString(actual: 'xyz'); + $this->assertGreaterThan(expected: 0, actual: 1); + } +} diff --git a/bridge/rector/src/Testing/Internal/RectorRunner.php b/bridge/rector/src/Testing/Internal/RectorRunner.php index f97fa263..3603e34d 100644 --- a/bridge/rector/src/Testing/Internal/RectorRunner.php +++ b/bridge/rector/src/Testing/Internal/RectorRunner.php @@ -92,6 +92,16 @@ public function assertConverts(Path $fixturePath): void $this->channel->debug("# Input:\n$input"); $this->channel->debug("# Expected:\n$expected"); + $changed = $this->convert($input, $fixturePath->name()); + + Assert::same($changed, $expected, \sprintf('Fixture "%s" was not converted as expected', $fixturePath->name())); + } + + /** + * Run the configured rules and return executable PHP for runtime regression tests. + */ + public function convert(string $input, string $name = 'input'): string + { $inputFile = $this->writeTempFile($input); try { @@ -121,7 +131,7 @@ public function assertConverts(Path $fixturePath): void throw new \RuntimeException(\sprintf( "Rector failed on fixture \"%s\":\n%s", - $fixturePath->name(), + $name, \implode("\n", \array_map(static fn(SystemError $error): string => $error->getMessage(), $errors)), ), previous: $crash); } @@ -131,7 +141,7 @@ public function assertConverts(Path $fixturePath): void @\unlink($inputFile); } - Assert::same($changed, $expected, \sprintf('Fixture "%s" was not converted as expected', $fixturePath->name())); + return $changed; } /** diff --git a/bridge/rector/tests/Feature/Fixture/ConvertedRuntime.php b/bridge/rector/tests/Feature/Fixture/ConvertedRuntime.php new file mode 100644 index 00000000..b8325685 --- /dev/null +++ b/bridge/rector/tests/Feature/Fixture/ConvertedRuntime.php @@ -0,0 +1,73 @@ +}> + */ + public static function run(string $source): array + { + $root = \dirname(__DIR__, 5); + $directory = \sys_get_temp_dir() . '/testo-converted-' . \bin2hex(\random_bytes(8)); + \mkdir($directory); + $input = $directory . '/converted.php'; + $config = $directory . '/testo.php'; + $junit = $directory . '/junit.xml'; + $stderr = $directory . '/stderr.txt'; + \file_put_contents($input, $source); + \file_put_contents($config, \sprintf(<<<'PHP' + ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['file', $stderr, 'w']], + $pipes, + $root, + ); + if (!\is_resource($process)) { + throw new \RuntimeException('Unable to start the converted test suite'); + } + \fclose($pipes[0]); + $stdout = (string) \stream_get_contents($pipes[1]); + \fclose($pipes[1]); + $exit = \proc_close($process); + $report = \json_decode($stdout, true); + if (!\in_array($exit, [0, 1], true) || !\is_array($report) || !isset($report['status']) || !\is_file($junit)) { + throw new \RuntimeException("Converted suite exited $exit without a report: $stdout\n" . \file_get_contents($stderr)); + } + $failures = \array_column($report['failures'], null, 'test'); + $results = []; + foreach ((new \SimpleXMLElement((string) \file_get_contents($junit)))->xpath('//testcase') as $case) { + $name = (string) $case['classname'] . '::' . (string) $case['name']; + $failure = $failures[$name] ?? null; + $results[$name] = [$failure['status'] ?? 'passed', (int) $case['assertions']]; + if (($failure['status'] ?? null) === 'error') { + $results[$name][] = $failure['exception']; + } + } + return $results; + } finally { + foreach ([$input, $config, $junit, $stderr] as $file) { + \is_file($file) and \unlink($file); + } + \rmdir($directory); + } + } +} diff --git a/bridge/rector/tests/Feature/TypedAssertionRuntimeTest.php b/bridge/rector/tests/Feature/TypedAssertionRuntimeTest.php new file mode 100644 index 00000000..c03a49d3 --- /dev/null +++ b/bridge/rector/tests/Feature/TypedAssertionRuntimeTest.php @@ -0,0 +1,62 @@ +assertFixture('runtime_weak', [ + 'WeakStringRuntimeContracts::testNamed' => ['passed', 2], + 'WeakStringRuntimeContracts::testNamedReversed' => ['passed', 2], + 'WeakStringRuntimeContracts::testWeakScalar' => ['passed', 1], + 'WeakStringRuntimeContracts::testPhpDocCannotReplaceNativeType' => ['passed', 1], + 'WeakStringRuntimeContracts::testWeakStringable' => ['passed', 2], + 'WeakStringRuntimeContracts::testPositionalAndNamedMessage' => ['passed', 2], + 'WeakStringRuntimeContracts::testFunctionNamedReversed' => ['passed', 1], + 'WeakStringRuntimeContracts::testSelfAndStatic' => ['passed', 2], + 'WeakStringRuntimeContracts::testWeakMessageScalar' => ['passed', 1], + 'WeakStringRuntimeContracts::testPositionalEvaluationOrder' => ['passed', 2], + 'WeakStringRuntimeContracts::testNecessaryFailure' => ['failed', 1], + 'WeakStringRuntimeContracts::testReturnRemainsNull' => ['passed', 2], + 'WeakStringRuntimeContracts::testPositionalReturnRemainsNull' => ['passed', 4], + ]); + } + + public function strictCallsKeepTypeErrorsBeforeAnyAssertion(): void + { + $this->assertFixture('runtime_strict', [ + 'StrictStringRuntimeContracts::testValidNamed' => ['passed', 1], + 'StrictStringRuntimeContracts::testIntegerMustError' => ['error', 0, \TypeError::class], + 'StrictStringRuntimeContracts::testStringableMustError' => ['error', 0, \TypeError::class], + 'StrictStringRuntimeContracts::testInvalidMessageMustError' => ['error', 0, \TypeError::class], + ]); + } + + /** + * @param array}> $expected + */ + private function assertFixture(string $name, array $expected): void + { + $file = \dirname(__DIR__, 2) . '/src/PhpunitToTesto/TypedAssertCallToTestoRector/' . $name . '.php.inc'; + [$input] = \explode("-----\n", \str_replace("\r\n", "\n", (string) \file_get_contents($file)), 2); + $runner = new RectorRunner(new RecordingMessenger(), [], [TestoRectorSetList::PHPUNIT_TO_TESTO]); + $converted = $runner->convert($input); + $actual = ConvertedRuntime::run($converted); + \ksort($actual); + \ksort($expected); + Assert::same($actual, $expected); + } +} diff --git a/bridge/rector/tests/suites.php b/bridge/rector/tests/suites.php index 0b30e559..ea9343f4 100644 --- a/bridge/rector/tests/suites.php +++ b/bridge/rector/tests/suites.php @@ -13,6 +13,7 @@ * `#[TestRectorFixtures]` and runs their co-located `*.php.inc` fixtures as data sets. * - `Bridge/Rector/Unit` holds the plain unit tests for the harness itself (path resolution, * the containment guard, the attribute). + * - `Bridge/Rector/Feature` executes converted tests through the Testo pipeline. */ return [ new SuiteConfig( @@ -28,4 +29,8 @@ include: [__DIR__ . '/Unit'], ), ), + new SuiteConfig( + name: 'Bridge/Rector/Feature', + location: new FinderConfig(include: [__DIR__ . '/Feature']), + ), ]; 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 25f43595..c6f8b3fb 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 @@ -16,6 +16,12 @@ the assertion **argument order flips** (see the pitfalls), and discovery is attr > The left column is mechanical; the right column is why **every** migration ends with an AI/human > pass and a test-count check against the PHPUnit run. +Simple standalone string assertions with native string arguments use a short chain, normalising +supported names positionally. Effectful/coercible arguments or a used result need a typed `void` +closure with source parameter names to retain binding, evaluation order and weak/strict coercion. +Other named assertion shapes remain manual. Do not simplify those closures to casts or reorder their +arguments: both can change TypeErrors or side effects. + ## Translation table | PHPUnit | Testo | From e191fda815ca07832da957cf90e61b951d45c5a6 Mon Sep 17 00:00:00 2001 From: Ichi Date: Fri, 16 Oct 2026 11:21:50 +0500 Subject: [PATCH 2/2] test(rector): avoid duplicated runtime assertion fixtures Keep weak and strict runtime inputs beside their feature tests instead of storing a second copy of the generated PHP. Retain all per-test outcomes, assertion counts and strict TypeError checks. Cover concise named calls in the existing contains fixture so the generated short form still has a golden check. Refs #390 Assisted-By: GPT-6 --- .../contains_list_property.php.inc | 12 + .../runtime_strict.php.inc | 63 ----- .../runtime_weak.php.inc | 227 ------------------ .../Feature/Fixture/runtime_strict.php.inc | 28 +++ .../Feature/Fixture/runtime_weak.php.inc | 103 ++++++++ .../Feature/TypedAssertionRuntimeTest.php | 3 +- 6 files changed, 144 insertions(+), 292 deletions(-) delete mode 100644 bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/runtime_strict.php.inc delete mode 100644 bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/runtime_weak.php.inc create mode 100644 bridge/rector/tests/Feature/Fixture/runtime_strict.php.inc create mode 100644 bridge/rector/tests/Feature/Fixture/runtime_weak.php.inc diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/contains_list_property.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/contains_list_property.php.inc index 23d55ca1..0f2f910c 100644 --- a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/contains_list_property.php.inc +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/contains_list_property.php.inc @@ -12,6 +12,12 @@ class ContainsListPropertyTest $this->assertIsList($rows); $this->assertIsList($rows, 'sequential keys'); } + + public function namedArguments(string $needle, string $haystack): void + { + $this->assertStringContainsString(needle: 'needle', haystack: 'haystack'); + $this->assertStringContainsString(haystack: $haystack, needle: $needle); + } } ----- isList(); \Testo\Assert::array($rows)->isList('sequential keys'); } + + public function namedArguments(string $needle, string $haystack): void + { + \Testo\Assert::string('haystack')->contains('needle'); + \Testo\Assert::string($haystack)->contains($needle); + } } diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/runtime_strict.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/runtime_strict.php.inc deleted file mode 100644 index 0df8c9ff..00000000 --- a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/runtime_strict.php.inc +++ /dev/null @@ -1,63 +0,0 @@ -assertStringContainsString(haystack: 'xyz', needle: 'x'); - } - - public function testIntegerMustError(): void - { - $this->assertStringContainsString('2', 123); - } - - public function testStringableMustError(): void - { - $this->assertStringContainsString('x', new class implements \Stringable { - public function __toString(): string { return 'xyz'; } - }); - } - - public function testInvalidMessageMustError(): void - { - $this->assertStringContainsString('x', 'xyz', message: 123); - } -} ------ -contains('x'); - } - - public function testIntegerMustError(): void - { - (static function (string $needle, string $haystack, string $message = ''): void { - \Testo\Assert::string($haystack)->contains($needle, $message); - })('2', 123); - } - - public function testStringableMustError(): void - { - (static function (string $needle, string $haystack, string $message = ''): void { - \Testo\Assert::string($haystack)->contains($needle, $message); - })('x', new class implements \Stringable { - public function __toString(): string { return 'xyz'; } - }); - } - - public function testInvalidMessageMustError(): void - { - (static function (string $needle, string $haystack, string $message = ''): void { - \Testo\Assert::string($haystack)->contains($needle, $message); - })('x', 'xyz', message: 123); - } -} diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/runtime_weak.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/runtime_weak.php.inc deleted file mode 100644 index 7f539174..00000000 --- a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/runtime_weak.php.inc +++ /dev/null @@ -1,227 +0,0 @@ -assertStringContainsString(needle: 'x', haystack: 'xyz'); - $needle = 'x'; - $haystack = 'xyz'; - $this->assertStringContainsString(haystack: $haystack, needle: $needle); - } - - public function testNamedReversed(): void - { - $this->trace = []; - $this->assertStringContainsString(haystack: $this->mark('haystack', 'xyz'), needle: $this->mark('needle', 'x'), message: $this->mark('message', 'named')); - \Testo\Assert::same($this->trace, ['haystack', 'needle', 'message']); - } - - public function testWeakScalar(): void - { - $this->assertStringContainsString('2', 123); - } - - public function testPhpDocCannotReplaceNativeType(): void - { - $this->annotatedHaystack(123); - } - - public function testWeakStringable(): void - { - $this->trace = []; - $this->assertStringContainsString('x', new class(fn() => $this->mark('cast', 'xyz')) implements \Stringable { - public function __construct(private \Closure $value) {} - public function __toString(): string { return ($this->value)(); } - }); - \Testo\Assert::same($this->trace, ['cast']); - } - - public function testPositionalAndNamedMessage(): void - { - $this->assertStringContainsString('x', 'xyz', message: 'present'); - $this->assertStringStartsWith(message: 'context', prefix: 'x', string: 'xyz'); - } - - public function testFunctionNamedReversed(): void - { - \PHPUnit\Framework\assertStringContainsString(haystack: 'xyz', needle: 'x'); - } - - public function testSelfAndStatic(): void - { - self::assertStringContainsString('2', 123); - static::assertStringContainsString(haystack: 'xyz', needle: 'x'); - } - - public function testWeakMessageScalar(): void - { - $this->assertStringContainsString('x', 'xyz', message: 123); - } - - public function testPositionalEvaluationOrder(): void - { - $this->trace = []; - $this->assertStringContainsString($this->mark('needle', 'x'), $this->mark('haystack', 'xyz'), $this->mark('message', 'present')); - \Testo\Assert::same($this->trace, ['needle', 'haystack', 'message']); - } - - public function testNecessaryFailure(): void - { - $this->assertStringContainsString('missing', 'xyz'); - } - - public function testReturnRemainsNull(): void - { - \Testo\Assert::same($this->assertStringContainsString(needle: 'x', haystack: 'xyz'), null); - } - - public function testPositionalReturnRemainsNull(): void - { - \Testo\Assert::same($this->assertStringContainsString('x', 'xyz'), null); - \Testo\Assert::same($this->assertionReturn(), null); - } - - private function mark(string $label, string $value): string - { - $this->trace[] = $label; - return $value; - } - - /** @param string $haystack */ - private function annotatedHaystack($haystack): void - { - $this->assertStringContainsString('2', $haystack); - } - - private function assertionReturn(): mixed - { - return $this->assertStringContainsString('x', 'xyz'); - } -} ------ -contains('x'); - $needle = 'x'; - $haystack = 'xyz'; - \Testo\Assert::string($haystack)->contains($needle); - } - - public function testNamedReversed(): void - { - $this->trace = []; - (static function (string $needle, string $haystack, string $message = ''): void { - \Testo\Assert::string($haystack)->contains($needle, $message); - })(haystack: $this->mark('haystack', 'xyz'), needle: $this->mark('needle', 'x'), message: $this->mark('message', 'named')); - \Testo\Assert::same($this->trace, ['haystack', 'needle', 'message']); - } - - public function testWeakScalar(): void - { - (static function (string $needle, string $haystack, string $message = ''): void { - \Testo\Assert::string($haystack)->contains($needle, $message); - })('2', 123); - } - - public function testPhpDocCannotReplaceNativeType(): void - { - $this->annotatedHaystack(123); - } - - public function testWeakStringable(): void - { - $this->trace = []; - (static function (string $needle, string $haystack, string $message = ''): void { - \Testo\Assert::string($haystack)->contains($needle, $message); - })('x', new class(fn() => $this->mark('cast', 'xyz')) implements \Stringable { - public function __construct(private \Closure $value) {} - public function __toString(): string { return ($this->value)(); } - }); - \Testo\Assert::same($this->trace, ['cast']); - } - - public function testPositionalAndNamedMessage(): void - { - \Testo\Assert::string('xyz')->contains('x', 'present'); - \Testo\Assert::string('xyz')->startsWith('x', 'context'); - } - - public function testFunctionNamedReversed(): void - { - \Testo\Assert::string('xyz')->contains('x'); - } - - public function testSelfAndStatic(): void - { - (static function (string $needle, string $haystack, string $message = ''): void { - \Testo\Assert::string($haystack)->contains($needle, $message); - })('2', 123); - \Testo\Assert::string('xyz')->contains('x'); - } - - public function testWeakMessageScalar(): void - { - (static function (string $needle, string $haystack, string $message = ''): void { - \Testo\Assert::string($haystack)->contains($needle, $message); - })('x', 'xyz', message: 123); - } - - public function testPositionalEvaluationOrder(): void - { - $this->trace = []; - (static function (string $needle, string $haystack, string $message = ''): void { - \Testo\Assert::string($haystack)->contains($needle, $message); - })($this->mark('needle', 'x'), $this->mark('haystack', 'xyz'), $this->mark('message', 'present')); - \Testo\Assert::same($this->trace, ['needle', 'haystack', 'message']); - } - - public function testNecessaryFailure(): void - { - \Testo\Assert::string('xyz')->contains('missing'); - } - - public function testReturnRemainsNull(): void - { - \Testo\Assert::same((static function (string $needle, string $haystack, string $message = ''): void { - \Testo\Assert::string($haystack)->contains($needle, $message); - })(needle: 'x', haystack: 'xyz'), null); - } - - public function testPositionalReturnRemainsNull(): void - { - \Testo\Assert::same((static function (string $needle, string $haystack, string $message = ''): void { - \Testo\Assert::string($haystack)->contains($needle, $message); - })('x', 'xyz'), null); - \Testo\Assert::same($this->assertionReturn(), null); - } - - private function mark(string $label, string $value): string - { - $this->trace[] = $label; - return $value; - } - - /** @param string $haystack */ - private function annotatedHaystack($haystack): void - { - (static function (string $needle, string $haystack, string $message = ''): void { - \Testo\Assert::string($haystack)->contains($needle, $message); - })('2', $haystack); - } - - private function assertionReturn(): mixed - { - return (static function (string $needle, string $haystack, string $message = ''): void { - \Testo\Assert::string($haystack)->contains($needle, $message); - })('x', 'xyz'); - } -} diff --git a/bridge/rector/tests/Feature/Fixture/runtime_strict.php.inc b/bridge/rector/tests/Feature/Fixture/runtime_strict.php.inc new file mode 100644 index 00000000..3ebffc37 --- /dev/null +++ b/bridge/rector/tests/Feature/Fixture/runtime_strict.php.inc @@ -0,0 +1,28 @@ +assertStringContainsString(haystack: 'xyz', needle: 'x'); + } + + public function testIntegerMustError(): void + { + $this->assertStringContainsString('2', 123); + } + + public function testStringableMustError(): void + { + $this->assertStringContainsString('x', new class implements \Stringable { + public function __toString(): string { return 'xyz'; } + }); + } + + public function testInvalidMessageMustError(): void + { + $this->assertStringContainsString('x', 'xyz', message: 123); + } +} diff --git a/bridge/rector/tests/Feature/Fixture/runtime_weak.php.inc b/bridge/rector/tests/Feature/Fixture/runtime_weak.php.inc new file mode 100644 index 00000000..c9a10714 --- /dev/null +++ b/bridge/rector/tests/Feature/Fixture/runtime_weak.php.inc @@ -0,0 +1,103 @@ +assertStringContainsString(needle: 'x', haystack: 'xyz'); + $needle = 'x'; + $haystack = 'xyz'; + $this->assertStringContainsString(haystack: $haystack, needle: $needle); + } + + public function testNamedReversed(): void + { + $this->trace = []; + $this->assertStringContainsString(haystack: $this->mark('haystack', 'xyz'), needle: $this->mark('needle', 'x'), message: $this->mark('message', 'named')); + \Testo\Assert::same($this->trace, ['haystack', 'needle', 'message']); + } + + public function testWeakScalar(): void + { + $this->assertStringContainsString('2', 123); + } + + public function testPhpDocCannotReplaceNativeType(): void + { + $this->annotatedHaystack(123); + } + + public function testWeakStringable(): void + { + $this->trace = []; + $this->assertStringContainsString('x', new class(fn() => $this->mark('cast', 'xyz')) implements \Stringable { + public function __construct(private \Closure $value) {} + public function __toString(): string { return ($this->value)(); } + }); + \Testo\Assert::same($this->trace, ['cast']); + } + + public function testPositionalAndNamedMessage(): void + { + $this->assertStringContainsString('x', 'xyz', message: 'present'); + $this->assertStringStartsWith(message: 'context', prefix: 'x', string: 'xyz'); + } + + public function testFunctionNamedReversed(): void + { + \PHPUnit\Framework\assertStringContainsString(haystack: 'xyz', needle: 'x'); + } + + public function testSelfAndStatic(): void + { + self::assertStringContainsString('2', 123); + static::assertStringContainsString(haystack: 'xyz', needle: 'x'); + } + + public function testWeakMessageScalar(): void + { + $this->assertStringContainsString('x', 'xyz', message: 123); + } + + public function testPositionalEvaluationOrder(): void + { + $this->trace = []; + $this->assertStringContainsString($this->mark('needle', 'x'), $this->mark('haystack', 'xyz'), $this->mark('message', 'present')); + \Testo\Assert::same($this->trace, ['needle', 'haystack', 'message']); + } + + public function testNecessaryFailure(): void + { + $this->assertStringContainsString('missing', 'xyz'); + } + + public function testReturnRemainsNull(): void + { + \Testo\Assert::same($this->assertStringContainsString(needle: 'x', haystack: 'xyz'), null); + } + + public function testPositionalReturnRemainsNull(): void + { + \Testo\Assert::same($this->assertStringContainsString('x', 'xyz'), null); + \Testo\Assert::same($this->assertionReturn(), null); + } + + private function mark(string $label, string $value): string + { + $this->trace[] = $label; + return $value; + } + + /** @param string $haystack */ + private function annotatedHaystack($haystack): void + { + $this->assertStringContainsString('2', $haystack); + } + + private function assertionReturn(): mixed + { + return $this->assertStringContainsString('x', 'xyz'); + } +} diff --git a/bridge/rector/tests/Feature/TypedAssertionRuntimeTest.php b/bridge/rector/tests/Feature/TypedAssertionRuntimeTest.php index c03a49d3..7724ded7 100644 --- a/bridge/rector/tests/Feature/TypedAssertionRuntimeTest.php +++ b/bridge/rector/tests/Feature/TypedAssertionRuntimeTest.php @@ -50,8 +50,7 @@ public function strictCallsKeepTypeErrorsBeforeAnyAssertion(): void */ private function assertFixture(string $name, array $expected): void { - $file = \dirname(__DIR__, 2) . '/src/PhpunitToTesto/TypedAssertCallToTestoRector/' . $name . '.php.inc'; - [$input] = \explode("-----\n", \str_replace("\r\n", "\n", (string) \file_get_contents($file)), 2); + $input = (string) \file_get_contents(__DIR__ . '/Fixture/' . $name . '.php.inc'); $runner = new RectorRunner(new RecordingMessenger(), [], [TestoRectorSetList::PHPUNIT_TO_TESTO]); $converted = $runner->convert($input); $actual = ConvertedRuntime::run($converted);