From 55cca5aa544b4551b22fb6511fd10f1da44d8869 Mon Sep 17 00:00:00 2001 From: Sam L Date: Thu, 31 Jul 2025 08:36:41 -0400 Subject: [PATCH 1/9] Initial commit, issue-6088 Add unit test for constraint --- .../Dictionary/IsIdenticalKeysValuesTest.php | 321 ++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 tests/unit/Framework/Constraint/Dictionary/IsIdenticalKeysValuesTest.php diff --git a/tests/unit/Framework/Constraint/Dictionary/IsIdenticalKeysValuesTest.php b/tests/unit/Framework/Constraint/Dictionary/IsIdenticalKeysValuesTest.php new file mode 100644 index 00000000000..92c1e9f61e4 --- /dev/null +++ b/tests/unit/Framework/Constraint/Dictionary/IsIdenticalKeysValuesTest.php @@ -0,0 +1,321 @@ + [ + false, + 'is identical to \'not-array\'', + 'assert(is_array($this->value))', + '', + 'not-array', + [], + ], + 'actual is not an array' => [ + false, + 'is identical to Array &0 []', + 'assert(is_array($other))', + '', + [], + '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; + } +} From deff9c3cc77228067e2578de2ceb00d437e96329 Mon Sep 17 00:00:00 2001 From: Sam L Date: Thu, 31 Jul 2025 18:08:38 -0400 Subject: [PATCH 2/9] Add constraint --- .../Dictionary/IsIdenticalKeysValues.php | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php diff --git a/src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php b/src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php new file mode 100644 index 00000000000..ba928247f44 --- /dev/null +++ b/src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php @@ -0,0 +1,242 @@ +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 + { + assert(is_array($this->value)); + assert(is_array($other)); + + // cribbed from `src/Framework/Constraint/Equality/IsEqualCanonicalizing.php` + try { + $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 + */ + 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 + */ + private function compareObjects(object $expected, object $actual, array &$processed = []) + { + 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` + */ + 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)); + } +} From b1456af050b47a23a2c74c539dc0e09ed28bccba Mon Sep 17 00:00:00 2001 From: Sam L Date: Thu, 31 Jul 2025 18:24:13 -0400 Subject: [PATCH 3/9] Add assert and unit tests --- src/Framework/Assert.php | 16 +++ .../assertSameDictionaryKeysValuesTest.php | 104 ++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 tests/unit/Framework/Assert/assertSameDictionaryKeysValuesTest.php diff --git a/src/Framework/Assert.php b/src/Framework/Assert.php index 91f940af36b..ddc4f330908 100644 --- a/src/Framework/Assert.php +++ b/src/Framework/Assert.php @@ -65,6 +65,7 @@ use PHPUnit\Framework\Constraint\TraversableContainsEqual; use PHPUnit\Framework\Constraint\TraversableContainsIdentical; use PHPUnit\Framework\Constraint\TraversableContainsOnly; +use PHPUnit\Framework\Constraint\Dictionary\IsIdenticalKeysValues; use PHPUnit\Util\Xml\Loader as XmlLoader; use PHPUnit\Util\Xml\XmlException; @@ -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(array $expected, array $actual, string $message = ''): void + { + self::assertThat( + $actual, + new IsIdenticalKeysValues($expected), + $message + ); + } + /** * Asserts that a variable is of a given type. * diff --git a/tests/unit/Framework/Assert/assertSameDictionaryKeysValuesTest.php b/tests/unit/Framework/Assert/assertSameDictionaryKeysValuesTest.php new file mode 100644 index 00000000000..30e9dfdd51c --- /dev/null +++ b/tests/unit/Framework/Assert/assertSameDictionaryKeysValuesTest.php @@ -0,0 +1,104 @@ + + */ + 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); + } +} From 66a2ba253340642b2b2c8032864cf070b691cbec Mon Sep 17 00:00:00 2001 From: Sam L Date: Thu, 31 Jul 2025 20:12:41 -0400 Subject: [PATCH 4/9] Fix styling with php-cs-fixer --- src/Framework/Assert.php | 4 +- .../Dictionary/IsIdenticalKeysValues.php | 58 ++++++++---- .../assertSameDictionaryKeysValuesTest.php | 77 ++++++++-------- .../Dictionary/IsIdenticalKeysValuesTest.php | 88 +++++++++++-------- 4 files changed, 134 insertions(+), 93 deletions(-) diff --git a/src/Framework/Assert.php b/src/Framework/Assert.php index ddc4f330908..c8e3e1ec5c0 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; @@ -65,7 +66,6 @@ use PHPUnit\Framework\Constraint\TraversableContainsEqual; use PHPUnit\Framework\Constraint\TraversableContainsIdentical; use PHPUnit\Framework\Constraint\TraversableContainsOnly; -use PHPUnit\Framework\Constraint\Dictionary\IsIdenticalKeysValues; use PHPUnit\Util\Xml\Loader as XmlLoader; use PHPUnit\Util\Xml\XmlException; @@ -1775,7 +1775,7 @@ final public static function assertSameDictionaryKeysValues(array $expected, arr self::assertThat( $actual, new IsIdenticalKeysValues($expected), - $message + $message, ); } diff --git a/src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php b/src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php index ba928247f44..62305a1aaa1 100644 --- a/src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php +++ b/src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php @@ -1,9 +1,25 @@ + * + * 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 assert; +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; @@ -42,11 +58,13 @@ public function evaluate(mixed $other, string $description = '', bool $returnRes if ($returnResult) { return false; } + throw new ExpectationFailedException( trim($description . "\n" . $f->getMessage()), - $f + $f, ); } + return true; } @@ -60,15 +78,15 @@ public function toString(): string /** * cribbed from `vendor/sebastian/comparator/src/ArrayComparator.php` - * This potentially should be a dictionarycomparator or type-strict arraycomparator + * This potentially should be a dictionarycomparator or type-strict arraycomparator. */ private function compareDictionary(array $expected, array $actual, array &$processed = []): void { - $remaining = $actual; - $actualAsString = "Array (\n"; + $remaining = $actual; + $actualAsString = "Array (\n"; $expectedAsString = "Array (\n"; - $equal = true; - $exporter = new Exporter; + $equal = true; + $exporter = new Exporter; foreach ($expected as $key => $value) { unset($remaining[$key]); @@ -80,6 +98,7 @@ private function compareDictionary(array $expected, array $actual, array &$proce $exporter->shortenedExport($value), ); $equal = false; + continue; } @@ -94,12 +113,13 @@ private function compareDictionary(array $expected, array $actual, array &$proce $exporter->export($actual[$key]), ); - // expected array, got array + // 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 + // type mismatch, expected object, got something else case is_object($value) && !is_object($actual[$key]): throw new ComparisonFailure( $value, @@ -108,16 +128,18 @@ private function compareDictionary(array $expected, array $actual, array &$proce $exporter->export($actual[$key]), ); - // type mismatch, expected object, got object + // 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 + // both are not array, both are not objects, strict comparison check default: if ($value === $actual[$key]) { continue 2; } + throw new ComparisonFailure( $value, $actual[$key], @@ -141,14 +163,14 @@ private function compareDictionary(array $expected, array $actual, array &$proce " %s => %s\n", $exporter->export($key), $e->getExpectedAsString() !== '' ? $this->indent( - $e->getExpectedAsString() + $e->getExpectedAsString(), ) : $exporter->shortenedExport($e->getExpected()), ); $actualAsString .= sprintf( " %s => %s\n", $exporter->export($key), $e->getActualAsString() !== '' ? $this->indent( - $e->getActualAsString() + $e->getActualAsString(), ) : $exporter->shortenedExport($e->getActual()), ); $equal = false; @@ -180,9 +202,9 @@ private function compareDictionary(array $expected, array $actual, array &$proce /** * cribbed from `vendor/sebastian/comparator/src/ObjectComparator.php` - * this potentially should be a type-strict objectcomparator + * this potentially should be a type-strict objectcomparator. */ - private function compareObjects(object $expected, object $actual, array &$processed = []) + private function compareObjects(object $expected, object $actual, array &$processed = []): void { if ($actual::class !== $expected::class) { $exporter = new Exporter; @@ -207,9 +229,11 @@ private function compareObjects(object $expected, object $actual, array &$proces } $processed[] = [$actual, $expected]; + if ($actual === $expected) { return; } + try { $this->compareDictionary($this->toArray($expected), $this->toArray($actual), $processed); } catch (ComparisonFailure $e) { @@ -225,7 +249,7 @@ private function compareObjects(object $expected, object $actual, array &$proces } /** - * cribbed from `vendor/sebastian/comparator/src/ObjectComparator.php` + * cribbed from `vendor/sebastian/comparator/src/ObjectComparator.php`. */ private function toArray(object $object): array { @@ -233,7 +257,7 @@ private function toArray(object $object): array } /** - * cribbed from `vendor/sebastian/comparator/src/ArrayComparator.php` + * cribbed from `vendor/sebastian/comparator/src/ArrayComparator.php`. */ private function indent(string $lines): string { diff --git a/tests/unit/Framework/Assert/assertSameDictionaryKeysValuesTest.php b/tests/unit/Framework/Assert/assertSameDictionaryKeysValuesTest.php index 30e9dfdd51c..54d511cb48e 100644 --- a/tests/unit/Framework/Assert/assertSameDictionaryKeysValuesTest.php +++ b/tests/unit/Framework/Assert/assertSameDictionaryKeysValuesTest.php @@ -1,5 +1,12 @@ + * + * 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; @@ -21,36 +28,36 @@ public static function successProvider(): array return [ [ [ - 'string' => 'string', - true => true, - 1 => 1, - 2 => 2.5, - 'object' => new stdClass(), - 'array' => [1, 2, 3], + '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], + true => true, + 1 => 1, + 2 => 2.5, + 'object' => new stdClass, + 'array' => [1, 2, 3], ], ], [ 'dictionary' => [ - 'object' => new stdClass(), - 'array' => [1, 2, 3], + 'object' => new stdClass, + 'array' => [1, 2, 3], 'string' => 'string', - true => true, - 1 => 1, - 2 => 2.5, + true => true, + 1 => 1, + 2 => 2.5, ], 'string' => 'string', - true => true, - 1 => 1, - 2 => 2.5, - 'object' => new stdClass(), - 'array' => [1, 2, 3], + true => true, + 1 => 1, + 2 => 2.5, + 'object' => new stdClass, + 'array' => [1, 2, 3], ], ], ]; @@ -64,25 +71,25 @@ public static function failureProvider(): array return [ [ [ - 'string' => 'string', - true => true, - 1 => 1, - 2 => 2.5, - 'object' => new stdClass(), - 'array' => [1, 2, 3], + '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], + true => true, + 1 => 1, + 2 => 2.5, + 'object' => new stdClass, + 'array' => [1, 2, 3], ], ], [ 'string' => 'string', - true => true, - 1 => 1, + true => true, + 1 => 1, ], ], ]; diff --git a/tests/unit/Framework/Constraint/Dictionary/IsIdenticalKeysValuesTest.php b/tests/unit/Framework/Constraint/Dictionary/IsIdenticalKeysValuesTest.php index 92c1e9f61e4..a3cba0d1fb9 100644 --- a/tests/unit/Framework/Constraint/Dictionary/IsIdenticalKeysValuesTest.php +++ b/tests/unit/Framework/Constraint/Dictionary/IsIdenticalKeysValuesTest.php @@ -1,7 +1,14 @@ + * + * 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; @@ -43,7 +50,7 @@ public static function provider(): array 'a' => 0, ] EOT -, + , 'Failed asserting that two arrays are equal.', <<<'EOT' Failed asserting that two arrays are equal. @@ -56,7 +63,7 @@ public static function provider(): array ) EOT -, + , ['a' => 0], [0], ], @@ -67,7 +74,7 @@ public static function provider(): array 0 => 0, ] EOT -, + , 'Failed asserting that two arrays are equal.', <<<'EOT' Failed asserting that two arrays are equal. @@ -80,7 +87,7 @@ public static function provider(): array ) EOT -, + , [0], ['a' => 0], ], @@ -91,7 +98,7 @@ public static function provider(): array 'a' => Array &%d [], ] EOT -, + , 'Failed asserting that two arrays are equal.', <<<'EOT' Failed asserting that two arrays are equal. @@ -104,7 +111,7 @@ public static function provider(): array ) EOT -, + , ['a' => []], ['a' => 0], ], @@ -115,7 +122,7 @@ public static function provider(): array 'a' => stdClass Object #%d (), ] EOT -, + , 'Failed asserting that two arrays are equal.', <<<'EOT' Failed asserting that two arrays are equal. @@ -128,8 +135,8 @@ public static function provider(): array ) EOT -, - ['a' => new stdClass()], + , + ['a' => new stdClass], ['a' => 0], ], 'expected object value does not match actual object value' => [ @@ -141,7 +148,7 @@ public static function provider(): array ), ] EOT -, + , 'Failed asserting that two arrays are equal.', <<<'EOT' Failed asserting that two arrays are equal. @@ -156,7 +163,7 @@ public static function provider(): array ) EOT -, + , ['a' => self::stdClass('a', 1)], ['a' => self::stdClass('a', '1')], ], @@ -176,18 +183,18 @@ public static function provider(): array 1 => 1, ] EOT -, + , '', '', [ 'string' => 'string', - true => true, - 1 => 1, + true => true, + 1 => 1, ], [ 'string' => 'string', - true => true, - 1 => 1, + true => true, + 1 => 1, ], ], 'value equality (string, bool, int, float, object, array, dictionary)' => [ @@ -220,39 +227,39 @@ public static function provider(): array ], ] EOT -, + , '', '', [ - '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, + true => true, + 1 => 1, + 2 => 2.5, 'object' => self::stdClass('key', 'value'), - 'array' => [1, 2, 3], + 'array' => [1, 2, 3], ], ], [ - '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, + true => true, + 1 => 1, + 2 => 2.5, 'object' => self::stdClass('key', 'value'), - 'array' => [1, 2, 3], + 'array' => [1, 2, 3], ], ], ], @@ -272,19 +279,22 @@ public function testCanBeEvaluated( 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() : '' + $e->getComparisonFailure() ? $e->getComparisonFailure()->toString() : '', ); + return; } From 66ce51519b3bab4e861d2f944ac778f5910f481d Mon Sep 17 00:00:00 2001 From: Sam L Date: Thu, 31 Jul 2025 20:33:11 -0400 Subject: [PATCH 5/9] Address phpstan issues --- src/Framework/Assert.php | 2 +- .../Constraint/Dictionary/IsIdenticalKeysValues.php | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Framework/Assert.php b/src/Framework/Assert.php index c8e3e1ec5c0..69ef2c47dd9 100644 --- a/src/Framework/Assert.php +++ b/src/Framework/Assert.php @@ -1770,7 +1770,7 @@ final public static function assertNotSame(mixed $expected, mixed $actual, strin * * @throws ExpectationFailedException */ - final public static function assertSameDictionaryKeysValues(array $expected, array $actual, string $message = ''): void + final public static function assertSameDictionaryKeysValues(mixed $expected, mixed $actual, string $message = ''): void { self::assertThat( $actual, diff --git a/src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php b/src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php index 62305a1aaa1..f969efe1504 100644 --- a/src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php +++ b/src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php @@ -46,7 +46,7 @@ public function __construct(mixed $value) * * @throws ExpectationFailedException */ - public function evaluate(mixed $other, string $description = '', bool $returnResult = false): ?bool + public function evaluate(mixed $other, string $description = '', bool $returnResult = false): bool { assert(is_array($this->value)); assert(is_array($other)); @@ -54,6 +54,7 @@ public function evaluate(mixed $other, string $description = '', bool $returnRes // cribbed from `src/Framework/Constraint/Equality/IsEqualCanonicalizing.php` try { $this->compareDictionary($this->value, $other); + } catch (ComparisonFailure $f) { if ($returnResult) { return false; @@ -64,7 +65,6 @@ public function evaluate(mixed $other, string $description = '', bool $returnRes $f, ); } - return true; } @@ -80,6 +80,7 @@ public function toString(): string * 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; @@ -204,6 +205,7 @@ private function compareDictionary(array $expected, array $actual, array &$proce * 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) { @@ -251,6 +253,7 @@ private function compareObjects(object $expected, object $actual, array &$proces /** * cribbed from `vendor/sebastian/comparator/src/ObjectComparator.php`. */ + /** @phpstan-ignore missingType.iterableValue */ private function toArray(object $object): array { return (new Exporter)->toArray($object); From 727b26079871058efaa268b832e72ab466f8026a Mon Sep 17 00:00:00 2001 From: Sam L Date: Fri, 1 Aug 2025 07:05:44 -0400 Subject: [PATCH 6/9] Throw exceptions when non-array values are expected or actual --- .../Dictionary/IsIdenticalKeysValues.php | 32 ++++++++++++++++--- .../Dictionary/IsIdenticalKeysValuesTest.php | 26 ++++++++++++--- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php b/src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php index f969efe1504..aebd9614d9f 100644 --- a/src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php +++ b/src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php @@ -12,7 +12,6 @@ namespace PHPUnit\Framework\Constraint\Dictionary; use function array_key_exists; -use function assert; use function in_array; use function is_array; use function is_object; @@ -48,13 +47,36 @@ public function __construct(mixed $value) */ public function evaluate(mixed $other, string $description = '', bool $returnResult = false): bool { - assert(is_array($this->value)); - assert(is_array($other)); - // cribbed from `src/Framework/Constraint/Equality/IsEqualCanonicalizing.php` try { - $this->compareDictionary($this->value, $other); + 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; diff --git a/tests/unit/Framework/Constraint/Dictionary/IsIdenticalKeysValuesTest.php b/tests/unit/Framework/Constraint/Dictionary/IsIdenticalKeysValuesTest.php index a3cba0d1fb9..992c6356bdd 100644 --- a/tests/unit/Framework/Constraint/Dictionary/IsIdenticalKeysValuesTest.php +++ b/tests/unit/Framework/Constraint/Dictionary/IsIdenticalKeysValuesTest.php @@ -30,16 +30,32 @@ public static function provider(): array 'expected is not an array' => [ false, 'is identical to \'not-array\'', - 'assert(is_array($this->value))', - '', + '\'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 &0 []', - 'assert(is_array($other))', - '', + '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', ], From 7dd60e9acd9f2b5889515b86fea0840643e644e7 Mon Sep 17 00:00:00 2001 From: Sam L Date: Fri, 1 Aug 2025 07:06:16 -0400 Subject: [PATCH 7/9] Add function support for `assertSameDictionaryKeysValues` --- src/Framework/Assert/Functions.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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. From 9a40b4c428fdf973a4348b392a1e96948e5b61ea Mon Sep 17 00:00:00 2001 From: Sam L Date: Fri, 1 Aug 2025 07:10:41 -0400 Subject: [PATCH 8/9] Address style issues --- .../Constraint/Dictionary/IsIdenticalKeysValues.php | 7 +++++-- .../Constraint/Dictionary/IsIdenticalKeysValuesTest.php | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php b/src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php index aebd9614d9f..088c872225a 100644 --- a/src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php +++ b/src/Framework/Constraint/Dictionary/IsIdenticalKeysValues.php @@ -12,6 +12,7 @@ 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; @@ -59,9 +60,10 @@ public function evaluate(mixed $other, string $description = '', bool $returnRes '%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([]), @@ -72,7 +74,7 @@ public function evaluate(mixed $other, string $description = '', bool $returnRes '%s is not an instance of %s', (new Exporter)->export(gettype($other)), (new Exporter)->export(gettype([])), - ) + ), ); } @@ -87,6 +89,7 @@ public function evaluate(mixed $other, string $description = '', bool $returnRes $f, ); } + return true; } diff --git a/tests/unit/Framework/Constraint/Dictionary/IsIdenticalKeysValuesTest.php b/tests/unit/Framework/Constraint/Dictionary/IsIdenticalKeysValuesTest.php index 992c6356bdd..aa80c472c6e 100644 --- a/tests/unit/Framework/Constraint/Dictionary/IsIdenticalKeysValuesTest.php +++ b/tests/unit/Framework/Constraint/Dictionary/IsIdenticalKeysValuesTest.php @@ -39,7 +39,7 @@ public static function provider(): array -'array' +'string' EOT -, + , 'not-array', [], ], From 5f47235763ea6d8817cac54f3c8215edd5ac1b16 Mon Sep 17 00:00:00 2001 From: Sam L Date: Sat, 2 Aug 2025 07:53:04 -0400 Subject: [PATCH 9/9] Correct attribute values --- .../Framework/Assert/assertSameDictionaryKeysValuesTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/Framework/Assert/assertSameDictionaryKeysValuesTest.php b/tests/unit/Framework/Assert/assertSameDictionaryKeysValuesTest.php index 54d511cb48e..5b1a53ae7b3 100644 --- a/tests/unit/Framework/Assert/assertSameDictionaryKeysValuesTest.php +++ b/tests/unit/Framework/Assert/assertSameDictionaryKeysValuesTest.php @@ -15,8 +15,8 @@ use PHPUnit\Framework\Attributes\TestDox; use stdClass; -#[CoversMethod(Assert::class, 'assertSameDictionaryKeysValuesTest')] -#[TestDox('assertSameDictionaryKeysValuesTest()')] +#[CoversMethod(Assert::class, 'assertSameDictionaryKeysValues')] +#[TestDox('assertSameDictionaryKeysValues()')] #[Small] final class assertSameDictionaryKeysValuesTest extends TestCase {