diff --git a/src/Framework/Assert.php b/src/Framework/Assert.php index 91f940af36b..69ef2c47dd9 100644 --- a/src/Framework/Assert.php +++ b/src/Framework/Assert.php @@ -25,6 +25,7 @@ use PHPUnit\Framework\Constraint\Callback; use PHPUnit\Framework\Constraint\Constraint; use PHPUnit\Framework\Constraint\Count; +use PHPUnit\Framework\Constraint\Dictionary\IsIdenticalKeysValues; use PHPUnit\Framework\Constraint\DirectoryExists; use PHPUnit\Framework\Constraint\FileExists; use PHPUnit\Framework\Constraint\GreaterThan; @@ -1763,6 +1764,21 @@ final public static function assertNotSame(mixed $expected, mixed $actual, strin ); } + /** + * Assert that two arrays have the same keys and values for those keys. + * The order of the keys is ignored. + * + * @throws ExpectationFailedException + */ + final public static function assertSameDictionaryKeysValues(mixed $expected, mixed $actual, string $message = ''): void + { + self::assertThat( + $actual, + new IsIdenticalKeysValues($expected), + $message, + ); + } + /** * Asserts that a variable is of a given type. * diff --git a/src/Framework/Assert/Functions.php b/src/Framework/Assert/Functions.php index 0645fd2629f..cce4b027bce 100644 --- a/src/Framework/Assert/Functions.php +++ b/src/Framework/Assert/Functions.php @@ -1837,6 +1837,23 @@ function assertNotSame(mixed $expected, mixed $actual, string $message = ''): vo } } +if (!function_exists('PHPUnit\Framework\assertSameDictionaryKeysValues')) { + /** + * Assert that two arrays have the same keys and values for those keys. + * The order of the keys is ignored. + * + * @throws ExpectationFailedException + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @see Assert::assertSameDictionaryKeysValues + */ + function assertSameDictionaryKeysValues(mixed $expected, mixed $actual, string $message = ''): void + { + Assert::assertSameDictionaryKeysValues(...func_get_args()); + } +} + if (!function_exists('PHPUnit\Framework\assertInstanceOf')) { /** * Asserts that a variable is of a given type. diff --git a/src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php b/src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php new file mode 100644 index 00000000000..088c872225a --- /dev/null +++ b/src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php @@ -0,0 +1,294 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Framework\Constraint\Dictionary; + +use function array_key_exists; +use function gettype; +use function in_array; +use function is_array; +use function is_object; +use function sprintf; +use function str_replace; +use function substr_replace; +use function trim; +use PHPUnit\Framework\Constraint\Constraint; +use PHPUnit\Framework\ExpectationFailedException; +use SebastianBergmann\Comparator\ComparisonFailure; +use SebastianBergmann\Exporter\Exporter; + +final class IsIdenticalKeysValues extends Constraint +{ + private readonly mixed $value; + + public function __construct(mixed $value) + { + $this->value = $value; + } + + /** + * Evaluates the constraint for parameter $other. + * + * If $returnResult is set to false (the default), an exception is thrown + * in case of a failure. null is returned otherwise. + * + * If $returnResult is true, the result of the evaluation is returned as + * a boolean value instead: true in case of success, false in case of a + * failure. + * + * @throws ExpectationFailedException + */ + public function evaluate(mixed $other, string $description = '', bool $returnResult = false): bool + { + // cribbed from `src/Framework/Constraint/Equality/IsEqualCanonicalizing.php` + try { + if (!is_array($this->value)) { + throw new ComparisonFailure( + gettype([]), + gettype($this->value), + (new Exporter)->export(gettype([])), + (new Exporter)->export(gettype($this->value)), + sprintf( + '%s is not an instance of %s', + (new Exporter)->export(gettype($this->value)), + (new Exporter)->export(gettype([])), + ), + ); + } + + if (!is_array($other)) { + throw new ComparisonFailure( + gettype([]), + gettype($other), + (new Exporter)->export(gettype([])), + (new Exporter)->export(gettype($other)), + sprintf( + '%s is not an instance of %s', + (new Exporter)->export(gettype($other)), + (new Exporter)->export(gettype([])), + ), + ); + } + + $this->compareDictionary($this->value, $other); + } catch (ComparisonFailure $f) { + if ($returnResult) { + return false; + } + + throw new ExpectationFailedException( + trim($description . "\n" . $f->getMessage()), + $f, + ); + } + + return true; + } + + /** + * Returns a string representation of the constraint. + */ + public function toString(): string + { + return 'is identical to ' . (new Exporter)->export($this->value); + } + + /** + * cribbed from `vendor/sebastian/comparator/src/ArrayComparator.php` + * This potentially should be a dictionarycomparator or type-strict arraycomparator. + */ + /** @phpstan-ignore missingType.iterableValue, missingType.iterableValue, missingType.iterableValue */ + private function compareDictionary(array $expected, array $actual, array &$processed = []): void + { + $remaining = $actual; + $actualAsString = "Array (\n"; + $expectedAsString = "Array (\n"; + $equal = true; + $exporter = new Exporter; + + foreach ($expected as $key => $value) { + unset($remaining[$key]); + + if (!array_key_exists($key, $actual)) { + $expectedAsString .= sprintf( + " %s => %s\n", + $exporter->export($key), + $exporter->shortenedExport($value), + ); + $equal = false; + + continue; + } + + try { + switch (true) { + // type mismatch, expected array, got something else + case is_array($value) && !is_array($actual[$key]): + throw new ComparisonFailure( + $value, + $actual[$key], + $exporter->export($value), + $exporter->export($actual[$key]), + ); + + // expected array, got array + case is_array($value) && is_array($actual[$key]): + $this->compareDictionary($value, $actual[$key]); + + break; + + // type mismatch, expected object, got something else + case is_object($value) && !is_object($actual[$key]): + throw new ComparisonFailure( + $value, + $actual[$key], + $exporter->export($value), + $exporter->export($actual[$key]), + ); + + // type mismatch, expected object, got object + case is_object($value) && is_object($actual[$key]): + $this->compareObjects($value, $actual[$key], $processed); + + break; + + // both are not array, both are not objects, strict comparison check + default: + if ($value === $actual[$key]) { + continue 2; + } + + throw new ComparisonFailure( + $value, + $actual[$key], + $exporter->export($value), + $exporter->export($actual[$key]), + ); + } + + $expectedAsString .= sprintf( + " %s => %s\n", + $exporter->export($key), + $exporter->shortenedExport($value), + ); + $actualAsString .= sprintf( + " %s => %s\n", + $exporter->export($key), + $exporter->shortenedExport($actual[$key]), + ); + } catch (ComparisonFailure $e) { + $expectedAsString .= sprintf( + " %s => %s\n", + $exporter->export($key), + $e->getExpectedAsString() !== '' ? $this->indent( + $e->getExpectedAsString(), + ) : $exporter->shortenedExport($e->getExpected()), + ); + $actualAsString .= sprintf( + " %s => %s\n", + $exporter->export($key), + $e->getActualAsString() !== '' ? $this->indent( + $e->getActualAsString(), + ) : $exporter->shortenedExport($e->getActual()), + ); + $equal = false; + } + } + + foreach ($remaining as $key => $value) { + $actualAsString .= sprintf( + " %s => %s\n", + $exporter->export($key), + $exporter->shortenedExport($value), + ); + $equal = false; + } + + $expectedAsString .= ')'; + $actualAsString .= ')'; + + if (!$equal) { + throw new ComparisonFailure( + $expected, + $actual, + $expectedAsString, + $actualAsString, + 'Failed asserting that two arrays are equal.', + ); + } + } + + /** + * cribbed from `vendor/sebastian/comparator/src/ObjectComparator.php` + * this potentially should be a type-strict objectcomparator. + */ + /** @phpstan-ignore missingType.iterableValue */ + private function compareObjects(object $expected, object $actual, array &$processed = []): void + { + if ($actual::class !== $expected::class) { + $exporter = new Exporter; + + throw new ComparisonFailure( + $expected, + $actual, + $exporter->export($expected), + $exporter->export($actual), + sprintf( + '%s is not instance of expected class "%s".', + $exporter->export($actual), + $expected::class, + ), + ); + } + + // don't compare twice to allow for cyclic dependencies + if (in_array([$actual, $expected], $processed, true) || + in_array([$expected, $actual], $processed, true)) { + return; + } + + $processed[] = [$actual, $expected]; + + if ($actual === $expected) { + return; + } + + try { + $this->compareDictionary($this->toArray($expected), $this->toArray($actual), $processed); + } catch (ComparisonFailure $e) { + throw new ComparisonFailure( + $expected, + $actual, + // replace "Array" with "MyClass object" + substr_replace($e->getExpectedAsString(), $expected::class . ' Object', 0, 5), + substr_replace($e->getActualAsString(), $actual::class . ' Object', 0, 5), + 'Failed asserting that two objects are equal.', + ); + } + } + + /** + * cribbed from `vendor/sebastian/comparator/src/ObjectComparator.php`. + */ + /** @phpstan-ignore missingType.iterableValue */ + private function toArray(object $object): array + { + return (new Exporter)->toArray($object); + } + + /** + * cribbed from `vendor/sebastian/comparator/src/ArrayComparator.php`. + */ + private function indent(string $lines): string + { + return trim(str_replace("\n", "\n ", $lines)); + } +} diff --git a/tests/unit/Framework/Assert/assertSameDictionaryKeysValuesTest.php b/tests/unit/Framework/Assert/assertSameDictionaryKeysValuesTest.php new file mode 100644 index 00000000000..5b1a53ae7b3 --- /dev/null +++ b/tests/unit/Framework/Assert/assertSameDictionaryKeysValuesTest.php @@ -0,0 +1,111 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Framework; + +use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\TestDox; +use stdClass; + +#[CoversMethod(Assert::class, 'assertSameDictionaryKeysValues')] +#[TestDox('assertSameDictionaryKeysValues()')] +#[Small] +final class assertSameDictionaryKeysValuesTest extends TestCase +{ + /** + * @return non-empty-list + */ + public static function successProvider(): array + { + return [ + [ + [ + 'string' => 'string', + true => true, + 1 => 1, + 2 => 2.5, + 'object' => new stdClass, + 'array' => [1, 2, 3], + 'dictionary' => [ + 'string' => 'string', + true => true, + 1 => 1, + 2 => 2.5, + 'object' => new stdClass, + 'array' => [1, 2, 3], + ], + ], + [ + 'dictionary' => [ + 'object' => new stdClass, + 'array' => [1, 2, 3], + 'string' => 'string', + true => true, + 1 => 1, + 2 => 2.5, + ], + 'string' => 'string', + true => true, + 1 => 1, + 2 => 2.5, + 'object' => new stdClass, + 'array' => [1, 2, 3], + ], + ], + ]; + } + + /** + * @return non-empty-list + */ + public static function failureProvider(): array + { + return [ + [ + [ + 'string' => 'string', + true => true, + 1 => 1, + 2 => 2.5, + 'object' => new stdClass, + 'array' => [1, 2, 3], + 'dictionary' => [ + 'string' => 'string', + true => true, + 1 => 1, + 2 => 2.5, + 'object' => new stdClass, + 'array' => [1, 2, 3], + ], + ], + [ + 'string' => 'string', + true => true, + 1 => 1, + ], + ], + ]; + } + + #[DataProvider('successProvider')] + public function testSucceedsWhenConstraintEvaluatesToTrue(mixed $expected, mixed $actual): void + { + $this->assertSameDictionaryKeysValues($expected, $actual); + } + + #[DataProvider('failureProvider')] + public function testFailsWhenConstraintEvaluatesToFalse(mixed $expected, mixed $actual): void + { + $this->expectException(AssertionFailedError::class); + + $this->assertSameDictionaryKeysValues($expected, $actual); + } +} diff --git a/tests/unit/Framework/Constraint/Dictionary/IsIdenticalKeysValuesTest.php b/tests/unit/Framework/Constraint/Dictionary/IsIdenticalKeysValuesTest.php new file mode 100644 index 00000000000..aa80c472c6e --- /dev/null +++ b/tests/unit/Framework/Constraint/Dictionary/IsIdenticalKeysValuesTest.php @@ -0,0 +1,347 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Framework\Constraint\Dictionary; + +use AssertionError; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Constraint\Constraint; +use PHPUnit\Framework\ExpectationFailedException; +use PHPUnit\Framework\TestCase; +use stdClass; + +#[CoversClass(IsIdenticalKeysValues::class)] +#[CoversClass(Constraint::class)] +#[Small] +final class IsIdenticalKeysValuesTest extends TestCase +{ + public static function provider(): array + { + return [ + 'expected is not an array' => [ + false, + 'is identical to \'not-array\'', + '\'string\' is not an instance of \'array\'', + <<<'EOT' +'string' is not an instance of 'array' +--- Expected ++++ Actual +@@ @@ +-'array' ++'string' +EOT + , + 'not-array', + [], + ], + 'actual is not an array' => [ + false, + 'is identical to Array &%d []', + '\'string\' is not an instance of \'array\'', + <<<'EOT' +'string' is not an instance of 'array' +--- Expected ++++ Actual +@@ @@ +-'array' ++'string' +EOT + , + [], + 'not-array', + ], + 'expected key missing from actual' => [ + false, + <<<'EOT' +is identical to Array &%d [ + 'a' => 0, +] +EOT + , + 'Failed asserting that two arrays are equal.', + <<<'EOT' +Failed asserting that two arrays are equal. +--- Expected ++++ Actual +@@ @@ + Array ( +- 'a' => 0 ++ 0 => 0 + ) + +EOT + , + ['a' => 0], + [0], + ], + 'actual has unexpected key' => [ + false, + <<<'EOT' +is identical to Array &%d [ + 0 => 0, +] +EOT + , + 'Failed asserting that two arrays are equal.', + <<<'EOT' +Failed asserting that two arrays are equal. +--- Expected ++++ Actual +@@ @@ + Array ( +- 0 => 0 ++ 'a' => 0 + ) + +EOT + , + [0], + ['a' => 0], + ], + 'expected value is array and actual value is not' => [ + false, + <<<'EOT' +is identical to Array &%d [ + 'a' => Array &%d [], +] +EOT + , + 'Failed asserting that two arrays are equal.', + <<<'EOT' +Failed asserting that two arrays are equal. +--- Expected ++++ Actual +@@ @@ + Array ( +- 'a' => Array &%d [] ++ 'a' => 0 + ) + +EOT + , + ['a' => []], + ['a' => 0], + ], + 'expected value is object and actual value is not' => [ + false, + <<<'EOT' +is identical to Array &%d [ + 'a' => stdClass Object #%d (), +] +EOT + , + 'Failed asserting that two arrays are equal.', + <<<'EOT' +Failed asserting that two arrays are equal. +--- Expected ++++ Actual +@@ @@ + Array ( +- 'a' => stdClass Object #%d () ++ 'a' => 0 + ) + +EOT + , + ['a' => new stdClass], + ['a' => 0], + ], + 'expected object value does not match actual object value' => [ + false, + <<<'EOT' +is identical to Array &%d [ + 'a' => stdClass Object #%d ( + 'a' => 1, + ), +] +EOT + , + 'Failed asserting that two arrays are equal.', + <<<'EOT' +Failed asserting that two arrays are equal. +--- Expected ++++ Actual +@@ @@ + Array ( + 'a' => stdClass Object ( +- 'a' => 1 ++ 'a' => '1' + ) + ) + +EOT + , + ['a' => self::stdClass('a', 1)], + ['a' => self::stdClass('a', '1')], + ], + 'empty arrays are equal' => [ + true, + 'is identical to Array &%d []', + '', + '', + [], + [], + ], + 'key equality (string, bool, int)' => [ + true, + <<<'EOT' +is identical to Array &%d [ + 'string' => 'string', + 1 => 1, +] +EOT + , + '', + '', + [ + 'string' => 'string', + true => true, + 1 => 1, + ], + [ + 'string' => 'string', + true => true, + 1 => 1, + ], + ], + 'value equality (string, bool, int, float, object, array, dictionary)' => [ + true, + <<<'EOT' +is identical to Array &%d [ + 'string' => 'string', + 1 => 1, + 2 => 2.5, + 'object' => stdClass Object #%d ( + 'key' => 'value', + ), + 'array' => Array &%d [ + 0 => 1, + 1 => 2, + 2 => 3, + ], + 'dictionary' => Array &%d [ + 'string' => 'string', + 1 => 1, + 2 => 2.5, + 'object' => stdClass Object #%d ( + 'key' => 'value', + ), + 'array' => Array &%d [ + 0 => 1, + 1 => 2, + 2 => 3, + ], + ], +] +EOT + , + '', + '', + [ + 'string' => 'string', + true => true, + 1 => 1, + 2 => 2.5, + 'object' => self::stdClass('key', 'value'), + 'array' => [1, 2, 3], + 'dictionary' => [ + 'string' => 'string', + true => true, + 1 => 1, + 2 => 2.5, + 'object' => self::stdClass('key', 'value'), + 'array' => [1, 2, 3], + ], + ], + [ + 'string' => 'string', + true => true, + 1 => 1, + 2 => 2.5, + 'object' => self::stdClass('key', 'value'), + 'array' => [1, 2, 3], + 'dictionary' => [ + 'string' => 'string', + true => true, + 1 => 1, + 2 => 2.5, + 'object' => self::stdClass('key', 'value'), + 'array' => [1, 2, 3], + ], + ], + ], + ]; + } + + #[DataProvider('provider')] + public function testCanBeEvaluated( + bool $result, + string $constraintAsString, + string $failureDescription, + string $comparisonFailureAsString, + mixed $expected, + mixed $actual + ): void { + $constraint = new IsIdenticalKeysValues($expected); + + try { + $this->assertSame($result, $constraint->evaluate($actual, returnResult: true)); + + if ($result) { + return; + } + $constraint->evaluate($actual); + } catch (AssertionError $e) { + $this->assertSame($failureDescription, $e->getMessage()); + + return; + } catch (ExpectationFailedException $e) { + $this->assertSame($failureDescription, $e->getMessage()); + $this->assertStringMatchesFormat( + $comparisonFailureAsString, + $e->getComparisonFailure() ? $e->getComparisonFailure()->toString() : '', + ); + + return; + } + + $this->fail(); + } + + #[DataProvider('provider')] + public function testCanBeRepresentedAsString( + bool $result, + string $constraintAsString, + string $failureDescription, + string $comparisonFailureAsString, + mixed $expected, + mixed $actual + ): void { + $constraint = new IsIdenticalKeysValues($expected); + + $this->assertStringMatchesFormat($constraintAsString, $constraint->toString()); + } + + public function testIsCountable(): void + { + $this->assertCount(1, (new IsIdenticalKeysValues([]))); + } + + private static function stdClass(string $key, mixed $value): stdClass + { + $o = new stdClass; + + $o->{$key} = $value; + + return $o; + } +}