From 851e6d59fd16d6382e9222563e766626fae396a0 Mon Sep 17 00:00:00 2001 From: Nick Harin Date: Wed, 9 Sep 2026 22:49:06 +0500 Subject: [PATCH 1/4] Add numeric tolerance and JSON matching assertions isCloseTo() defaults to PHP_FLOAT_EPSILON: enough for float arithmetic noise, too small to hide a wrong value. --- AGENTS.md | 7 +- CHANGELOG.md | 27 ++ README.md | 73 +++- src/FluentAssertions.php | 2 + ...luentAssertionsTypeSpecifyingExtension.php | 24 +- src/Traits/JsonAssertions.php | 381 ++++++++++++++++++ src/Traits/NumericAssertions.php | 168 ++++++++ src/Traits/StringAssertions.php | 101 ----- tests/Fixtures/expected.json | 4 + .../Asserts/ContainsJson/ContainsJsonTest.php | 64 +++ .../ContainsJson/NotContainsJsonTest.php | 53 +++ .../Asserts/HasJsonPath/HasJsonPathTest.php | 56 +++ .../HasJsonPath/NotHasJsonPathTest.php | 52 +++ .../Asserts/IsCloseTo/IsCloseToTest.php | 74 ++++ .../Asserts/IsCloseTo/NotCloseToTest.php | 70 ++++ .../Asserts/IsFinite/IsFiniteTest.php | 56 +++ .../IsGreaterThanOrEqualTest.php | 56 +++ .../IsLowerThanOrEqualTest.php | 56 +++ .../Asserts/IsNan/IsNanTest.php | 45 +++ .../Asserts/IsNotZero/IsNotZeroTest.php | 54 +++ .../Asserts/JsonPath/JsonPathTest.php | 62 +++ .../Asserts/MatchesJson/MatchesJsonTest.php | 20 +- .../MatchesJson/NotMatchesJsonTest.php | 14 +- .../MatchesJsonFile/MatchesJsonFileTest.php | 47 +++ tests/PHPStan/data/narrowing.php | 60 +++ 25 files changed, 1500 insertions(+), 126 deletions(-) create mode 100644 src/Traits/JsonAssertions.php create mode 100644 tests/Fixtures/expected.json create mode 100644 tests/FluentAssertions/Asserts/ContainsJson/ContainsJsonTest.php create mode 100644 tests/FluentAssertions/Asserts/ContainsJson/NotContainsJsonTest.php create mode 100644 tests/FluentAssertions/Asserts/HasJsonPath/HasJsonPathTest.php create mode 100644 tests/FluentAssertions/Asserts/HasJsonPath/NotHasJsonPathTest.php create mode 100644 tests/FluentAssertions/Asserts/IsCloseTo/IsCloseToTest.php create mode 100644 tests/FluentAssertions/Asserts/IsCloseTo/NotCloseToTest.php create mode 100644 tests/FluentAssertions/Asserts/IsFinite/IsFiniteTest.php create mode 100644 tests/FluentAssertions/Asserts/IsGreaterThanOrEqual/IsGreaterThanOrEqualTest.php create mode 100644 tests/FluentAssertions/Asserts/IsLowerThanOrEqual/IsLowerThanOrEqualTest.php create mode 100644 tests/FluentAssertions/Asserts/IsNan/IsNanTest.php create mode 100644 tests/FluentAssertions/Asserts/IsNotZero/IsNotZeroTest.php create mode 100644 tests/FluentAssertions/Asserts/JsonPath/JsonPathTest.php create mode 100644 tests/FluentAssertions/Asserts/MatchesJsonFile/MatchesJsonFileTest.php diff --git a/AGENTS.md b/AGENTS.md index d06b864..1bf146e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,10 +20,13 @@ This file outlines the requirements and best practices for adding new assertion - `BooleanAssertions`: True/false assertions. - `NullAssertions`: Null checks. - `NumericAssertions`: Numeric comparisons (e.g., isPositive, isBetween). - - `StringAssertions`: String operations (e.g., startsWith, hasLength). + - `StringAssertions`: String operations (e.g., startsWith, hasLength, ulid). + - `JsonAssertions`: JSON documents (e.g., matchesJson, containsJson, jsonPath). - `ArrayAssertions`: Array checks (e.g., contains, hasSize). - `TypeCheckingAssertions`: Type validation (e.g., isInt, instanceOf, hasProperty). - - `SpecialAssertions`: Specialized checks (e.g., ULID). + - `ExceptionAssertions`: Thrown exceptions (throws). + - `DateTimeAssertions`: Date/time comparisons (e.g., isBefore, isSameDate). + - `EnumAssertions`: Native enum cases (e.g., isEnum, hasValue). - Traits are imported into `FluentAssertions` class using `use` statements. - Place new methods in the appropriate trait based on functionality. diff --git a/CHANGELOG.md b/CHANGELOG.md index 05f96bd..0136829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ All notable changes to this package are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [12.11.0] - 2026-09-09 + +### Added + +- Numeric assertions `isCloseTo()` / `notCloseTo()`, comparing within a tolerance that + defaults to `PHP_FLOAT_EPSILON` — enough for accumulated float noise, while money and + percentages pass a delta of their own. +- The non-strict comparisons `isGreaterThanOrEqual()` / `isLowerThanOrEqual()`, plus + `isNotZero()`, `isFinite()` and `isNan()`. +- JSON subset matching with `containsJson()` / `notContainsJson()`: only the keys named in + the expectation are compared, so volatile fields in a response no longer have to be + spelled out. +- JSON path assertions `jsonPath()`, `hasJsonPath()` and `notHasJsonPath()`, addressing a + single value by a dot-separated path (`data.0.id`). +- `matchesJsonFile()`, the fixture-file counterpart of `matchesJson()`. +- `matchesJson()` and `notMatchesJson()` now also accept the expectation as an array or + object, which keeps `json_encode()` out of the test. +- The PHPStan extension narrows the subject of every JSON assertion to `string`, of + `isCloseTo()` / `notCloseTo()` / `isFinite()` to `int|float`, and of `isNan()` to `float`. + +### Changed + +- The JSON assertions moved from `StringAssertions` into a `JsonAssertions` trait. The + public API is unchanged; only code using the trait directly is affected. + ## [12.10.0] - 2026-07-07 ### Added @@ -52,6 +77,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Fixed latent static-analysis errors and ran the CI matrix across PHP 8.2–8.5 (with a PHP 8.1 source/static-analysis job). +[12.11.0]: https://github.com/k2gl/phpunit-fluent-assertions/compare/12.10.0...12.11.0 +[12.10.0]: https://github.com/k2gl/phpunit-fluent-assertions/compare/12.9.0...12.10.0 [12.9.0]: https://github.com/k2gl/phpunit-fluent-assertions/compare/12.8.0...12.9.0 [12.8.0]: https://github.com/k2gl/phpunit-fluent-assertions/compare/12.7.0...12.8.0 [12.7.0]: https://github.com/k2gl/phpunit-fluent-assertions/compare/12.6.0...12.7.0 diff --git a/README.md b/README.md index 23715da..99b5918 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,33 @@ fact(1)->isZero(); // Fails fact(5)->isBetween(1, 10); // Passes fact(15)->isBetween(1, 10); // Fails + +fact(10)->isGreaterThanOrEqual(10); // Passes +fact(5)->isGreaterThanOrEqual(10); // Fails + +fact(5)->isLowerThanOrEqual(5); // Passes +fact(10)->isLowerThanOrEqual(5); // Fails + +fact(1)->isNotZero(); // Passes +fact(0.0)->isNotZero(); // Fails + +fact(1.5)->isFinite(); // Passes +fact(INF)->isFinite(); // Fails + +fact(sqrt(-1))->isNan(); // Passes +fact(1.0)->isNan(); // Fails +``` + +Comparing floats with a tolerance — the delta defaults to `PHP_FLOAT_EPSILON`, which +covers accumulated arithmetic noise. It is an absolute tolerance, so money, percentages +and values far from `1.0` want a delta of their own. +```php +fact(0.1 + 0.2)->isCloseTo(0.3); // Passes — strict comparison would not +fact(99.985)->isCloseTo(99.99, 0.005); // Passes +fact(99.9)->isCloseTo(99.99, 0.005); // Fails + +fact(1.0)->notCloseTo(2.0); // Passes +fact(0.1 + 0.2)->notCloseTo(0.3); // Fails ``` ### String assertions @@ -174,20 +201,53 @@ fact('hello')->isEmptyString(); // Fails fact('hello')->isNotEmptyString(); // Passes fact('')->isNotEmptyString(); // Fails +fact('user@example.com')->isValidEmail(); // Passes +fact('invalid-email')->isValidEmail(); // Fails + +fact('01ARZ3NDEKTSV4RRFFQ69G5FAV')->ulid(); // Passes (if valid ULID) +fact('invalid-ulid')->ulid(); // Fails +``` + +### JSON assertions +The subject is a JSON string. Documents are compared by value, so object key order and +formatting never matter; array element order does. An expectation can be written as JSON +text or as the array/object it would encode to, which keeps `json_encode()` out of the test. +```php fact('{"key": "value"}')->isJson(); // Passes fact('invalid json')->isJson(); // Fails fact('{"a":1,"b":2}')->matchesJson('{"b":2,"a":1}'); // Passes (key order ignored) +fact('{"a":1,"b":2}')->matchesJson(['b' => 2, 'a' => 1]); // Passes fact('{"a":1}')->matchesJson('{"a":2}'); // Fails fact('{"a":1}')->notMatchesJson('{"a":2}'); // Passes fact('{"a":1,"b":2}')->notMatchesJson('{"b":2,"a":1}'); // Fails -fact('user@example.com')->isValidEmail(); // Passes -fact('invalid-email')->isValidEmail(); // Fails +fact($canonicalJson)->matchesJsonFile(__DIR__ . '/fixtures/bundle.json'); // Passes when equal +``` -fact('01ARZ3NDEKTSV4RRFFQ69G5FAV')->ulid(); // Passes (if valid ULID) -fact('invalid-ulid')->ulid(); // Fails +`containsJson()` matches a subset: every key named in the expectation must be present with +a matching value, and everything else in the document is ignored. That is what makes it +usable against responses carrying volatile fields. Nesting is walked recursively and list +positions count as keys, so `['tags' => ['a']]` matches `{"tags":["a","b"]}` but not +`{"tags":["b","a"]}`. Scalars are compared strictly. +```php +fact('{"id":42,"created_at":"2026-01-01"}')->containsJson(['id' => 42]); // Passes +fact('{"data":{"id":42,"role":"admin"}}')->containsJson(['data' => ['id' => 42]]); // Passes +fact('{"id":42}')->containsJson(['id' => '42']); // Fails — strict comparison + +fact('{"id":42}')->notContainsJson(['id' => 43]); // Passes +fact('{"id":42}')->notContainsJson(['id' => 42]); // Fails +``` + +Single values are reachable by a dot-separated path; segments address object keys and list +positions alike. Keys that contain a dot are not addressable this way. +```php +fact('{"data":[{"id":42}]}')->jsonPath('data.0.id', 42); // Passes +fact('{"meta":{"total":3}}')->jsonPath('meta.total', '3'); // Fails — strict comparison + +fact('{"data":{"id":42}}')->hasJsonPath('data.id'); // Passes +fact('{"data":{"id":42}}')->notHasJsonPath('data.name'); // Passes ``` ### Type Checking assertions @@ -330,8 +390,9 @@ includes: Narrowing is applied for `notNull()`, `null()`, `true()`, `notTrue()`, `false()`, `notFalse()`, `instanceOf()`, `notInstanceOf()`, `is()`, the type checks `isString()`, -`isInt()`, `isFloat()`, `isBool()`, `isArray()`, `isCallable()` and `isResource()`, and the JSON -assertions `isJson()`, `matchesJson()` and `notMatchesJson()` (subject narrowed to `string`). Loose +`isInt()`, `isFloat()`, `isBool()`, `isArray()`, `isCallable()` and `isResource()`, every JSON +assertion (subject narrowed to `string`), and the numeric assertions that guard their subject — +`isCloseTo()`, `notCloseTo()` and `isFinite()` (to `int|float`) and `isNan()` (to `float`). Loose or negated assertions such as `equals()` (loose `==`) and `not()` would not narrow soundly, so they are intentionally left out and leave the type unchanged. diff --git a/src/FluentAssertions.php b/src/FluentAssertions.php index 268812c..df6015b 100644 --- a/src/FluentAssertions.php +++ b/src/FluentAssertions.php @@ -10,6 +10,7 @@ use K2gl\PHPUnitFluentAssertions\Traits\DateTimeAssertions; use K2gl\PHPUnitFluentAssertions\Traits\EnumAssertions; use K2gl\PHPUnitFluentAssertions\Traits\ExceptionAssertions; +use K2gl\PHPUnitFluentAssertions\Traits\JsonAssertions; use K2gl\PHPUnitFluentAssertions\Traits\NullAssertions; use K2gl\PHPUnitFluentAssertions\Traits\NumericAssertions; use K2gl\PHPUnitFluentAssertions\Traits\StringAssertions; @@ -22,6 +23,7 @@ class FluentAssertions use NullAssertions; use NumericAssertions; use StringAssertions; + use JsonAssertions; use ArrayAssertions; use TypeCheckingAssertions; use ExceptionAssertions; diff --git a/src/PHPStan/FluentAssertionsTypeSpecifyingExtension.php b/src/PHPStan/FluentAssertionsTypeSpecifyingExtension.php index da2fe1e..e60df7e 100644 --- a/src/PHPStan/FluentAssertionsTypeSpecifyingExtension.php +++ b/src/PHPStan/FluentAssertionsTypeSpecifyingExtension.php @@ -8,6 +8,7 @@ use K2gl\PHPUnitFluentAssertions\FluentAssertions; use PhpParser\Node\Arg; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\BinaryOp\BooleanOr; use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\BinaryOp\NotIdentical; use PhpParser\Node\Expr\BooleanNot; @@ -195,9 +196,21 @@ private static function getResolvers(): array 'isresource' => static fn (Expr $s): Expr => self::isType('is_resource', $s), // JSON assertions can only pass on a (valid JSON) string subject. - 'isjson' => static fn (Expr $s): Expr => self::isType('is_string', $s), - 'matchesjson' => static fn (Expr $s): Expr => self::isType('is_string', $s), - 'notmatchesjson' => static fn (Expr $s): Expr => self::isType('is_string', $s), + 'isjson' => static fn (Expr $s): Expr => self::isType('is_string', $s), + 'matchesjson' => static fn (Expr $s): Expr => self::isType('is_string', $s), + 'notmatchesjson' => static fn (Expr $s): Expr => self::isType('is_string', $s), + 'matchesjsonfile' => static fn (Expr $s): Expr => self::isType('is_string', $s), + 'containsjson' => static fn (Expr $s): Expr => self::isType('is_string', $s), + 'notcontainsjson' => static fn (Expr $s): Expr => self::isType('is_string', $s), + 'jsonpath' => static fn (Expr $s): Expr => self::isType('is_string', $s), + 'hasjsonpath' => static fn (Expr $s): Expr => self::isType('is_string', $s), + 'nothasjsonpath' => static fn (Expr $s): Expr => self::isType('is_string', $s), + + // Numeric assertions that guard the subject type before comparing. + 'iscloseto' => static fn (Expr $s): Expr => self::isNumeric($s), + 'notcloseto' => static fn (Expr $s): Expr => self::isNumeric($s), + 'isfinite' => static fn (Expr $s): Expr => self::isNumeric($s), + 'isnan' => static fn (Expr $s): Expr => self::isType('is_float', $s), ]; } @@ -211,6 +224,11 @@ private static function isType(string $function, Expr $subject): FuncCall return new FuncCall(new Name($function), [new Arg($subject)]); } + private static function isNumeric(Expr $subject): BooleanOr + { + return new BooleanOr(self::isType('is_int', $subject), self::isType('is_float', $subject)); + } + /** * @param array $args */ diff --git a/src/Traits/JsonAssertions.php b/src/Traits/JsonAssertions.php new file mode 100644 index 0000000..4d6692f --- /dev/null +++ b/src/Traits/JsonAssertions.php @@ -0,0 +1,381 @@ +isJson(); // Passes + * fact('invalid json')->isJson(); // Fails + * + * @param string $message Optional custom error message. + * + * @return self Enables fluent chaining of assertion methods. + */ + public function isJson(string $message = ''): self + { + if (! is_string($this->variable)) { + Assert::fail($message ?: 'Variable is not a string.'); + } + + Assert::assertTrue(json_validate($this->variable), $message ?: 'String is not valid JSON.'); + + return $this; + } + + // endregion Validity + + // region Whole-document Matching + + /** + * Asserts that a variable is a JSON string semantically equal to the expected document. + * + * Both documents are compared by value, so object key order and formatting are + * ignored (`{"a":1,"b":2}` matches `{"b":2,"a":1}`); array element order stays + * significant. The expectation may be given as JSON text or as the array/object it + * would encode to, which keeps `json_encode()` calls out of the test. + * + * Example usage: + * fact('{"a":1,"b":2}')->matchesJson('{"b":2,"a":1}'); // Passes + * fact('{"a":1,"b":2}')->matchesJson(['b' => 2, 'a' => 1]); // Passes + * fact('{"a":1}')->matchesJson('{"a":2}'); // Fails + * + * @param string|array|object $expected The expected document, as JSON text or as a value to encode. + * @param string $message Optional custom error message. + * + * @return self Enables fluent chaining of assertion methods. + */ + public function matchesJson(string|array|object $expected, string $message = ''): self + { + $actual = $this->decodeJsonSubject($message); + + Assert::assertEquals( + $this->decodeJsonExpectation($expected, $message), + $actual, + $message, + ); + + return $this; + } + + /** + * Asserts that a variable is a JSON string not equal to the given document. + * + * The inverse of matchesJson(): both sides must be valid JSON and must differ by + * value (object key order and formatting are still ignored). + * + * Example usage: + * fact('{"a":1}')->notMatchesJson('{"a":2}'); // Passes + * fact('{"a":1,"b":2}')->notMatchesJson('{"b":2,"a":1}'); // Fails + * + * @param string|array|object $expected The document the variable must not equal. + * @param string $message Optional custom error message. + * + * @return self Enables fluent chaining of assertion methods. + */ + public function notMatchesJson(string|array|object $expected, string $message = ''): self + { + $actual = $this->decodeJsonSubject($message); + + Assert::assertNotEquals( + $this->decodeJsonExpectation($expected, $message), + $actual, + $message, + ); + + return $this; + } + + /** + * Asserts that a variable is a JSON string semantically equal to the document in a file. + * + * The file-based counterpart of matchesJson(), for tests that keep the expected + * payload as a fixture instead of a literal. + * + * Example usage: + * fact($canonicalJson)->matchesJsonFile(__DIR__ . '/fixtures/bundle.json'); // Passes when equal + * + * @param string $path Path to a readable file holding the expected JSON document. + * @param string $message Optional custom error message. + * + * @return self Enables fluent chaining of assertion methods. + */ + public function matchesJsonFile(string $path, string $message = ''): self + { + return $this->matchesJson($this->readJsonFile($path, $message), $message); + } + + // endregion Whole-document Matching + + // region Subset Matching + + /** + * Asserts that a JSON document contains the expected subset. + * + * Every key named in the expectation must exist in the document with a matching + * value; keys that are not named are ignored, which is what makes this usable + * against responses carrying volatile fields (`created_at`, `_links`, ...). + * Nesting is walked recursively, and list positions count as keys — so + * `['tags' => ['a']]` matches `{"tags":["a","b"]}` (index 0 is "a") but not + * `{"tags":["b","a"]}`. Scalars are compared strictly. + * + * Example usage: + * fact('{"id":42,"created_at":"..."}')->containsJson(['id' => 42]); // Passes + * fact('{"id":42}')->containsJson(['id' => 43]); // Fails + * + * @param string|array|object $expected The expected subset, as JSON text or as a value to encode. + * @param string $message Optional custom error message. + * + * @return self Enables fluent chaining of assertion methods. + */ + public function containsJson(string|array|object $expected, string $message = ''): self + { + [$document, $subset] = $this->decodeJsonSubsetPair($expected, $message); + + Assert::assertTrue( + $this->arrayContainsAssociativeArrayRecursive($document, $subset), + $message ?: sprintf( + "JSON document does not contain the expected subset.\n\nDocument: %s\n\nExpected subset: %s", + self::encodeJsonForMessage($document), + self::encodeJsonForMessage($subset), + ), + ); + + return $this; + } + + /** + * Asserts that a JSON document does not contain the given subset. + * + * The inverse of containsJson(), with the same matching rules. + * + * Example usage: + * fact('{"id":42}')->notContainsJson(['id' => 43]); // Passes + * fact('{"id":42}')->notContainsJson(['id' => 42]); // Fails + * + * @param string|array|object $expected The subset the document must not contain. + * @param string $message Optional custom error message. + * + * @return self Enables fluent chaining of assertion methods. + */ + public function notContainsJson(string|array|object $expected, string $message = ''): self + { + [$document, $subset] = $this->decodeJsonSubsetPair($expected, $message); + + Assert::assertFalse( + $this->arrayContainsAssociativeArrayRecursive($document, $subset), + $message ?: sprintf( + "JSON document contains the subset it should not.\n\nDocument: %s\n\nUnexpected subset: %s", + self::encodeJsonForMessage($document), + self::encodeJsonForMessage($subset), + ), + ); + + return $this; + } + + // endregion Subset Matching + + // region Path Matching + + /** + * Asserts that the value at a dot-separated path in a JSON document is the expected one. + * + * Path segments address object keys and list positions alike (`data.0.id`). The + * value is compared strictly, so `1` does not match `"1"`. A path that does not + * exist fails the assertion. Keys that themselves contain a dot are not addressable. + * + * Example usage: + * fact('{"data":[{"id":42}]}')->jsonPath('data.0.id', 42); // Passes + * fact('{"meta":{"total":3}}')->jsonPath('meta.total', 4); // Fails + * + * @param string $path Dot-separated path into the document. + * @param mixed $expected The expected value at that path. + * @param string $message Optional custom error message. + * + * @return self Enables fluent chaining of assertion methods. + */ + public function jsonPath(string $path, mixed $expected, string $message = ''): self + { + [$found, $value] = $this->resolveJsonPath($this->decodeJsonSubject($message, associative: true), $path); + + if (! $found) { + Assert::fail($message ?: sprintf('JSON document has no value at path "%s".', $path)); + } + + Assert::assertSame($expected, $value, $message); + + return $this; + } + + /** + * Asserts that a dot-separated path exists in a JSON document, whatever its value. + * + * Example usage: + * fact('{"data":{"id":42}}')->hasJsonPath('data.id'); // Passes + * fact('{"data":{"id":42}}')->hasJsonPath('data.name'); // Fails + * + * @param string $path Dot-separated path into the document. + * @param string $message Optional custom error message. + * + * @return self Enables fluent chaining of assertion methods. + */ + public function hasJsonPath(string $path, string $message = ''): self + { + [$found] = $this->resolveJsonPath($this->decodeJsonSubject($message, associative: true), $path); + + Assert::assertTrue($found, $message ?: sprintf('JSON document has no value at path "%s".', $path)); + + return $this; + } + + /** + * Asserts that a dot-separated path is absent from a JSON document. + * + * A path whose value is null still counts as present; use jsonPath() to assert null. + * + * Example usage: + * fact('{"data":{"id":42}}')->notHasJsonPath('data.name'); // Passes + * fact('{"data":{"id":42}}')->notHasJsonPath('data.id'); // Fails + * + * @param string $path Dot-separated path into the document. + * @param string $message Optional custom error message. + * + * @return self Enables fluent chaining of assertion methods. + */ + public function notHasJsonPath(string $path, string $message = ''): self + { + [$found] = $this->resolveJsonPath($this->decodeJsonSubject($message, associative: true), $path); + + Assert::assertFalse($found, $message ?: sprintf('JSON document has a value at path "%s".', $path)); + + return $this; + } + + // endregion Path Matching + + /** + * Decodes the subject, failing the assertion unless it is a valid JSON string. + */ + private function decodeJsonSubject(string $message, bool $associative = false): mixed + { + if (! is_string($this->variable)) { + Assert::fail($message ?: 'Variable is not a string.'); + } + + $decoded = json_decode($this->variable, $associative); + + if (json_last_error() !== JSON_ERROR_NONE) { + Assert::fail($message ?: 'Variable is not valid JSON.'); + } + + return $decoded; + } + + /** + * Brings an expectation given as JSON text or as a plain value into decoded form, + * so both sides of a comparison are shaped the same way. + * + * @param string|array|object $expected + */ + private function decodeJsonExpectation(string|array|object $expected, string $message, bool $associative = false): mixed + { + if (! is_string($expected)) { + $encoded = json_encode($expected); + + if ($encoded === false) { + Assert::fail($message ?: 'Expected value cannot be encoded as JSON.'); + } + + $expected = $encoded; + } + + $decoded = json_decode($expected, $associative); + + if (json_last_error() !== JSON_ERROR_NONE) { + Assert::fail($message ?: 'Expected value is not valid JSON.'); + } + + return $decoded; + } + + /** + * Decodes both sides of a subset comparison as arrays. + * + * @param string|array|object $expected + * + * @return array{array, array} + */ + private function decodeJsonSubsetPair(string|array|object $expected, string $message): array + { + $document = $this->decodeJsonSubject($message, associative: true); + $subset = $this->decodeJsonExpectation($expected, $message, associative: true); + + if (! is_array($document)) { + Assert::fail($message ?: 'Variable is not a JSON object or array.'); + } + + if (! is_array($subset)) { + Assert::fail($message ?: 'Expected subset is not a JSON object or array.'); + } + + return [$document, $subset]; + } + + /** + * Walks a dot-separated path. + * + * @return array{bool, mixed} Whether the path exists, and the value found there. + */ + private function resolveJsonPath(mixed $document, string $path): array + { + $current = $document; + + foreach (explode('.', $path) as $segment) { + if (! is_array($current) || ! array_key_exists($segment, $current)) { + return [false, null]; + } + + $current = $current[$segment]; + } + + return [true, $current]; + } + + private static function encodeJsonForMessage(mixed $value): string + { + $encoded = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + return $encoded === false ? var_export($value, true) : $encoded; + } + + private function readJsonFile(string $path, string $message): string + { + if (! is_file($path) || ! is_readable($path)) { + Assert::fail($message ?: sprintf('JSON file "%s" does not exist or is not readable.', $path)); + } + + $contents = file_get_contents($path); + + if ($contents === false) { + Assert::fail($message ?: sprintf('JSON file "%s" could not be read.', $path)); + } + + return $contents; + } +} diff --git a/src/Traits/NumericAssertions.php b/src/Traits/NumericAssertions.php index 025d714..b73e688 100644 --- a/src/Traits/NumericAssertions.php +++ b/src/Traits/NumericAssertions.php @@ -58,8 +58,110 @@ public function isGreaterThan(int|float $expected, string $message = ''): self return $this; } + /** + * Asserts that a numeric value is greater than or equal to another numeric value. + * + * The non-strict counterpart of isGreaterThan(). + * + * Example usage: + * fact(10)->isGreaterThanOrEqual(10); // Passes + * fact(5)->isGreaterThanOrEqual(10); // Fails + * + * @param int|float $expected The value to compare against. + * @param string $message Optional custom error message. + * + * @return self Enables fluent chaining of assertion methods. + */ + public function isGreaterThanOrEqual(int|float $expected, string $message = ''): self + { + Assert::assertGreaterThanOrEqual($expected, $this->variable, $message); + + return $this; + } + + /** + * Asserts that a numeric value is lower than or equal to another numeric value. + * + * The non-strict counterpart of isLowerThan(). + * + * Example usage: + * fact(5)->isLowerThanOrEqual(5); // Passes + * fact(10)->isLowerThanOrEqual(5); // Fails + * + * @param int|float $expected The value to compare against. + * @param string $message Optional custom error message. + * + * @return self Enables fluent chaining of assertion methods. + */ + public function isLowerThanOrEqual(int|float $expected, string $message = ''): self + { + Assert::assertLessThanOrEqual($expected, $this->variable, $message); + + return $this; + } + // endregion Comparison Methods + // region Tolerance + + /** + * Asserts that a numeric value equals the expected one within a tolerance. + * + * The default delta is PHP_FLOAT_EPSILON, which covers the usual reason floats + * fail a strict comparison — accumulated arithmetic noise. It is an absolute + * tolerance, so magnitudes far from 1.0 and domain rounding (money, percentages) + * need a delta of their own. + * + * Example usage: + * fact(0.1 + 0.2)->isCloseTo(0.3); // Passes + * fact(99.985)->isCloseTo(99.99, 0.005); // Passes + * fact(99.9)->isCloseTo(99.99, 0.005); // Fails + * + * @param int|float $expected The value to compare against. + * @param float $delta The maximum accepted absolute difference. + * @param string $message Optional custom error message. + * + * @return self Enables fluent chaining of assertion methods. + */ + public function isCloseTo(int|float $expected, float $delta = PHP_FLOAT_EPSILON, string $message = ''): self + { + if (! is_int($this->variable) && ! is_float($this->variable)) { + Assert::fail($message ?: 'Variable is not numeric.'); + } + + Assert::assertEqualsWithDelta($expected, $this->variable, $delta, $message); + + return $this; + } + + /** + * Asserts that a numeric value differs from the expected one by more than a tolerance. + * + * The inverse of isCloseTo(), with the same default delta. + * + * Example usage: + * fact(1.0)->notCloseTo(2.0); // Passes + * fact(0.1 + 0.2)->notCloseTo(0.3); // Fails + * + * @param int|float $expected The value to compare against. + * @param float $delta The maximum difference that would still count as equal. + * @param string $message Optional custom error message. + * + * @return self Enables fluent chaining of assertion methods. + */ + public function notCloseTo(int|float $expected, float $delta = PHP_FLOAT_EPSILON, string $message = ''): self + { + if (! is_int($this->variable) && ! is_float($this->variable)) { + Assert::fail($message ?: 'Variable is not numeric.'); + } + + Assert::assertNotEqualsWithDelta($expected, $this->variable, $delta, $message); + + return $this; + } + + // endregion Tolerance + // region Sign Checks /** @@ -161,5 +263,71 @@ public function isBetween(int|float $min, int|float $max, string $message = ''): return $this; } + /** + * Asserts that a numeric value is not zero. + * + * The inverse of isZero(). + * + * Example usage: + * fact(1)->isNotZero(); // Passes + * fact(0.0)->isNotZero(); // Fails + * + * @param string $message Optional custom error message. + * + * @return self Enables fluent chaining of assertion methods. + */ + public function isNotZero(string $message = ''): self + { + Assert::assertNotEquals(0, $this->variable, $message); + + return $this; + } + + /** + * Asserts that a numeric value is finite — neither an infinity nor NAN. + * + * Example usage: + * fact(1.5)->isFinite(); // Passes + * fact(INF)->isFinite(); // Fails + * + * @param string $message Optional custom error message. + * + * @return self Enables fluent chaining of assertion methods. + */ + public function isFinite(string $message = ''): self + { + if (! is_int($this->variable) && ! is_float($this->variable)) { + Assert::fail($message ?: 'Variable is not numeric.'); + } + + Assert::assertTrue(is_finite((float) $this->variable), $message ?: 'Failed asserting that the value is finite.'); + + return $this; + } + + /** + * Asserts that a float is NAN. + * + * NAN is not equal to itself, so the usual equality assertions cannot express this. + * + * Example usage: + * fact(sqrt(-1))->isNan(); // Passes + * fact(1.0)->isNan(); // Fails + * + * @param string $message Optional custom error message. + * + * @return self Enables fluent chaining of assertion methods. + */ + public function isNan(string $message = ''): self + { + if (! is_float($this->variable)) { + Assert::fail($message ?: 'Variable is not a float.'); + } + + Assert::assertTrue(is_nan($this->variable), $message ?: 'Failed asserting that the value is NAN.'); + + return $this; + } + // endregion Value Checks } diff --git a/src/Traits/StringAssertions.php b/src/Traits/StringAssertions.php index 8ff47c6..6a66a8b 100644 --- a/src/Traits/StringAssertions.php +++ b/src/Traits/StringAssertions.php @@ -330,107 +330,6 @@ public function isNotEmptyString(string $message = ''): self return $this; } - /** - * Asserts that a string is valid JSON. - * - * This method checks if the actual string is valid JSON. - * - * Example usage: - * fact('{"key": "value"}')->isJson(); // Passes - * fact('invalid json')->isJson(); // Fails - * - * @param string $message Optional custom error message. - * - * @return self Enables fluent chaining of assertion methods. - */ - public function isJson(string $message = ''): self - { - if (! is_string($this->variable)) { - Assert::fail($message ?: 'Variable is not a string.'); - } - - Assert::assertTrue(json_validate($this->variable), $message ?: 'String is not valid JSON.'); - - return $this; - } - - /** - * Asserts that a variable is a JSON string semantically equal to the expected JSON. - * - * Both documents are decoded and compared by value, so object key order and - * formatting are ignored (`{"a":1,"b":2}` matches `{"b":2,"a":1}`); array element - * order stays significant. Both sides must be valid JSON. - * - * Example usage: - * fact('{"a":1,"b":2}')->matchesJson('{"b":2,"a":1}'); // Passes - * fact('{"a":1}')->matchesJson('{"a":2}'); // Fails - * - * @param string $expectedJson The expected JSON document. - * @param string $message Optional custom error message. - * - * @return self Enables fluent chaining of assertion methods. - */ - public function matchesJson(string $expectedJson, string $message = ''): self - { - if (! is_string($this->variable)) { - Assert::fail($message ?: 'Variable is not a string.'); - } - - $actual = json_decode($this->variable); - - if (json_last_error() !== JSON_ERROR_NONE) { - Assert::fail($message ?: 'Variable is not valid JSON.'); - } - - $expected = json_decode($expectedJson); - - if (json_last_error() !== JSON_ERROR_NONE) { - Assert::fail($message ?: 'Expected value is not valid JSON.'); - } - - Assert::assertEquals($expected, $actual, $message); - - return $this; - } - - /** - * Asserts that a variable is a JSON string not equal to the given JSON. - * - * The inverse of matchesJson(): both sides must be valid JSON and must differ by - * value (object key order and formatting are still ignored). - * - * Example usage: - * fact('{"a":1}')->notMatchesJson('{"a":2}'); // Passes - * fact('{"a":1,"b":2}')->notMatchesJson('{"b":2,"a":1}'); // Fails - * - * @param string $expectedJson The JSON document the variable must not equal. - * @param string $message Optional custom error message. - * - * @return self Enables fluent chaining of assertion methods. - */ - public function notMatchesJson(string $expectedJson, string $message = ''): self - { - if (! is_string($this->variable)) { - Assert::fail($message ?: 'Variable is not a string.'); - } - - $actual = json_decode($this->variable); - - if (json_last_error() !== JSON_ERROR_NONE) { - Assert::fail($message ?: 'Variable is not valid JSON.'); - } - - $expected = json_decode($expectedJson); - - if (json_last_error() !== JSON_ERROR_NONE) { - Assert::fail($message ?: 'Expected value is not valid JSON.'); - } - - Assert::assertNotEquals($expected, $actual, $message); - - return $this; - } - /** * Asserts that a string is a valid email address. * diff --git a/tests/Fixtures/expected.json b/tests/Fixtures/expected.json new file mode 100644 index 0000000..2e3e9a9 --- /dev/null +++ b/tests/Fixtures/expected.json @@ -0,0 +1,4 @@ +{ + "id": 42, + "name": "Ada" +} diff --git a/tests/FluentAssertions/Asserts/ContainsJson/ContainsJsonTest.php b/tests/FluentAssertions/Asserts/ContainsJson/ContainsJsonTest.php new file mode 100644 index 0000000..fa37eec --- /dev/null +++ b/tests/FluentAssertions/Asserts/ContainsJson/ContainsJsonTest.php @@ -0,0 +1,64 @@ +containsJson($expected); + + // assert + $this->correctAssertionExecuted(); + } + + #[DataProvider('notContainingDataProvider')] + public function testFailsWhenSubsetIsAbsentOrInvalid(mixed $variable, mixed $expected): void + { + // assert + $this->incorrectAssertionExpected(); + + // act + fact($variable)->containsJson($expected); + } + + public static function containingDataProvider(): array + { + return [ + 'volatile fields ignored' => ['{"id":42,"created_at":"2026-01-01"}', ['id' => 42]], + 'expectation as json' => ['{"id":42,"name":"Ada"}', '{"id":42}'], + 'nested subset' => ['{"data":{"id":42,"role":"admin"}}', ['data' => ['id' => 42]]], + 'list prefix' => ['{"tags":["a","b","c"]}', ['tags' => ['a', 'b']]], + 'empty subset' => ['{"id":42}', []], + 'null value' => ['{"deleted_at":null}', ['deleted_at' => null]], + ]; + } + + public static function notContainingDataProvider(): array + { + return [ + 'different value' => ['{"id":42}', ['id' => 43]], + 'loose comparison' => ['{"id":42}', ['id' => '42']], + 'missing key' => ['{"id":42}', ['name' => 'Ada']], + 'list order' => ['{"tags":["a","b"]}', ['tags' => ['b']]], + 'nested mismatch' => ['{"data":{"id":42}}', ['data' => ['id' => 43]]], + 'invalid actual' => ['not json', ['id' => 42]], + 'invalid expected' => ['{"id":42}', 'not json'], + 'scalar document' => ['42', ['id' => 42]], + 'scalar subset' => ['{"id":42}', '42'], + 'subject not string' => [42, ['id' => 42]], + ]; + } +} diff --git a/tests/FluentAssertions/Asserts/ContainsJson/NotContainsJsonTest.php b/tests/FluentAssertions/Asserts/ContainsJson/NotContainsJsonTest.php new file mode 100644 index 0000000..20a8a37 --- /dev/null +++ b/tests/FluentAssertions/Asserts/ContainsJson/NotContainsJsonTest.php @@ -0,0 +1,53 @@ +notContainsJson($expected); + + // assert + $this->correctAssertionExecuted(); + } + + #[DataProvider('containingDataProvider')] + public function testFailsWhenSubsetIsPresent(string $variable, string|array $expected): void + { + // assert + $this->incorrectAssertionExpected(); + + // act + fact($variable)->notContainsJson($expected); + } + + public static function notContainingDataProvider(): array + { + return [ + 'different value' => ['{"id":42}', ['id' => 43]], + 'missing key' => ['{"id":42}', ['name' => 'Ada']], + 'nested mismatch' => ['{"data":{"id":42}}', ['data' => ['id' => 43]]], + ]; + } + + public static function containingDataProvider(): array + { + return [ + 'present subset' => ['{"id":42,"name":"Ada"}', ['id' => 42]], + 'expectation as json' => ['{"id":42,"name":"Ada"}', '{"name":"Ada"}'], + ]; + } +} diff --git a/tests/FluentAssertions/Asserts/HasJsonPath/HasJsonPathTest.php b/tests/FluentAssertions/Asserts/HasJsonPath/HasJsonPathTest.php new file mode 100644 index 0000000..4692f12 --- /dev/null +++ b/tests/FluentAssertions/Asserts/HasJsonPath/HasJsonPathTest.php @@ -0,0 +1,56 @@ +hasJsonPath($path); + + // assert + $this->correctAssertionExecuted(); + } + + #[DataProvider('missingPathDataProvider')] + public function testFailsWhenPathIsMissing(mixed $variable, string $path): void + { + // assert + $this->incorrectAssertionExpected(); + + // act + fact($variable)->hasJsonPath($path); + } + + public static function existingPathDataProvider(): array + { + return [ + 'top level key' => ['{"id":42}', 'id'], + 'nested key' => ['{"data":{"id":42}}', 'data.id'], + 'list position' => ['{"data":[{"id":42}]}', 'data.0'], + 'null value' => ['{"deleted_at":null}', 'deleted_at'], + ]; + } + + public static function missingPathDataProvider(): array + { + return [ + 'missing key' => ['{"data":{"id":42}}', 'data.name'], + 'path into scalar' => ['{"id":42}', 'id.0'], + 'invalid json' => ['not json', 'id'], + 'not a string' => [42, 'id'], + ]; + } +} diff --git a/tests/FluentAssertions/Asserts/HasJsonPath/NotHasJsonPathTest.php b/tests/FluentAssertions/Asserts/HasJsonPath/NotHasJsonPathTest.php new file mode 100644 index 0000000..4618eb7 --- /dev/null +++ b/tests/FluentAssertions/Asserts/HasJsonPath/NotHasJsonPathTest.php @@ -0,0 +1,52 @@ +notHasJsonPath($path); + + // assert + $this->correctAssertionExecuted(); + } + + #[DataProvider('existingPathDataProvider')] + public function testFailsWhenPathExists(string $variable, string $path): void + { + // assert + $this->incorrectAssertionExpected(); + + // act + fact($variable)->notHasJsonPath($path); + } + + public static function missingPathDataProvider(): array + { + return [ + 'missing key' => ['{"data":{"id":42}}', 'data.name'], + 'path into scalar' => ['{"id":42}', 'id.0'], + ]; + } + + public static function existingPathDataProvider(): array + { + return [ + 'top level key' => ['{"id":42}', 'id'], + 'null value' => ['{"deleted_at":null}', 'deleted_at'], + ]; + } +} diff --git a/tests/FluentAssertions/Asserts/IsCloseTo/IsCloseToTest.php b/tests/FluentAssertions/Asserts/IsCloseTo/IsCloseToTest.php new file mode 100644 index 0000000..c1b40cd --- /dev/null +++ b/tests/FluentAssertions/Asserts/IsCloseTo/IsCloseToTest.php @@ -0,0 +1,74 @@ +isCloseTo($expected, $delta); + + // assert + $this->correctAssertionExecuted(); + } + + public function testAbsorbsFloatArithmeticNoiseByDefault(): void + { + // act + fact(0.1 + 0.2)->isCloseTo(0.3); + + // assert + $this->correctAssertionExecuted(); + } + + #[DataProvider('notIsCloseToDataProvider')] + public function testNotIsCloseTo(mixed $variable, int|float $expected, float $delta): void + { + // assert + $this->incorrectAssertionExpected(); + + // act + fact($variable)->isCloseTo($expected, $delta); + } + + public function testFailsWhenSubjectIsNotNumeric(): void + { + // assert + $this->incorrectAssertionExpected(); + + // act + fact('99.99')->isCloseTo(99.99, 0.005); + } + + public static function isCloseToDataProvider(): array + { + return [ + 'within delta' => [99.985, 99.99, 0.005], + 'exactly on delta' => [1.5, 1.0, 0.5], + 'exact match' => [1.0, 1.0, 0.0], + 'integer subject' => [10, 10.4, 0.5], + 'negative values' => [-1.004, -1.0, 0.005], + ]; + } + + public static function notIsCloseToDataProvider(): array + { + return [ + 'outside delta' => [99.9, 99.99, 0.005], + 'zero delta' => [0.1 + 0.2, 0.3, 0.0], + 'wrong sign' => [1.0, -1.0, 0.5], + ]; + } +} diff --git a/tests/FluentAssertions/Asserts/IsCloseTo/NotCloseToTest.php b/tests/FluentAssertions/Asserts/IsCloseTo/NotCloseToTest.php new file mode 100644 index 0000000..7c4df3b --- /dev/null +++ b/tests/FluentAssertions/Asserts/IsCloseTo/NotCloseToTest.php @@ -0,0 +1,70 @@ +notCloseTo($expected, $delta); + + // assert + $this->correctAssertionExecuted(); + } + + #[DataProvider('closeToDataProvider')] + public function testFailsWhenWithinDelta(mixed $variable, int|float $expected, float $delta): void + { + // assert + $this->incorrectAssertionExpected(); + + // act + fact($variable)->notCloseTo($expected, $delta); + } + + public function testFailsOnFloatArithmeticNoiseByDefault(): void + { + // assert + $this->incorrectAssertionExpected(); + + // act + fact(0.1 + 0.2)->notCloseTo(0.3); + } + + public function testFailsWhenSubjectIsNotNumeric(): void + { + // assert + $this->incorrectAssertionExpected(); + + // act + fact('1.0')->notCloseTo(2.0, 0.5); + } + + public static function notCloseToDataProvider(): array + { + return [ + 'outside delta' => [1.0, 2.0, 0.5], + 'integer subject' => [10, 20, 0.5], + ]; + } + + public static function closeToDataProvider(): array + { + return [ + 'within delta' => [99.985, 99.99, 0.005], + 'exactly on delta' => [1.5, 1.0, 0.5], + ]; + } +} diff --git a/tests/FluentAssertions/Asserts/IsFinite/IsFiniteTest.php b/tests/FluentAssertions/Asserts/IsFinite/IsFiniteTest.php new file mode 100644 index 0000000..abddaf5 --- /dev/null +++ b/tests/FluentAssertions/Asserts/IsFinite/IsFiniteTest.php @@ -0,0 +1,56 @@ +isFinite(); + + // assert + $this->correctAssertionExecuted(); + } + + #[DataProvider('notIsFiniteDataProvider')] + public function testNotIsFinite(mixed $variable): void + { + // assert + $this->incorrectAssertionExpected(); + + // act + fact($variable)->isFinite(); + } + + public static function isFiniteDataProvider(): array + { + return [ + 'int' => [42], + 'float' => [1.5], + 'zero' => [0.0], + 'huge float' => [1.0e308], + ]; + } + + public static function notIsFiniteDataProvider(): array + { + return [ + 'infinity' => [INF], + 'negative infinity' => [-INF], + 'nan' => [NAN], + 'numeric string' => ['1.5'], + ]; + } +} diff --git a/tests/FluentAssertions/Asserts/IsGreaterThanOrEqual/IsGreaterThanOrEqualTest.php b/tests/FluentAssertions/Asserts/IsGreaterThanOrEqual/IsGreaterThanOrEqualTest.php new file mode 100644 index 0000000..3b734cc --- /dev/null +++ b/tests/FluentAssertions/Asserts/IsGreaterThanOrEqual/IsGreaterThanOrEqualTest.php @@ -0,0 +1,56 @@ +isGreaterThanOrEqual($expected); + + // assert + // assertGreaterThanOrEqual()/assertLessThanOrEqual() build a composite constraint, + // so PHPUnit counts two assertions for a single fluent call. + $this->correctAssertionsExecuted(expected: 2); + } + + #[DataProvider('notIsGreaterThanOrEqualDataProvider')] + public function testNotIsGreaterThanOrEqual(int|float $variable, int|float $expected): void + { + // assert + $this->incorrectAssertionExpected(); + + // act + fact($variable)->isGreaterThanOrEqual($expected); + } + + public static function isGreaterThanOrEqualDataProvider(): array + { + return [ + 'greater' => [10, 5], + 'equal' => [10, 10], + 'equal floats' => [1.5, 1.5], + 'mixed types' => [10, 9.5], + ]; + } + + public static function notIsGreaterThanOrEqualDataProvider(): array + { + return [ + 'lower' => [5, 10], + 'lower floats' => [1.4, 1.5], + ]; + } +} diff --git a/tests/FluentAssertions/Asserts/IsLowerThanOrEqual/IsLowerThanOrEqualTest.php b/tests/FluentAssertions/Asserts/IsLowerThanOrEqual/IsLowerThanOrEqualTest.php new file mode 100644 index 0000000..4de9ac5 --- /dev/null +++ b/tests/FluentAssertions/Asserts/IsLowerThanOrEqual/IsLowerThanOrEqualTest.php @@ -0,0 +1,56 @@ +isLowerThanOrEqual($expected); + + // assert + // assertGreaterThanOrEqual()/assertLessThanOrEqual() build a composite constraint, + // so PHPUnit counts two assertions for a single fluent call. + $this->correctAssertionsExecuted(expected: 2); + } + + #[DataProvider('notIsLowerThanOrEqualDataProvider')] + public function testNotIsLowerThanOrEqual(int|float $variable, int|float $expected): void + { + // assert + $this->incorrectAssertionExpected(); + + // act + fact($variable)->isLowerThanOrEqual($expected); + } + + public static function isLowerThanOrEqualDataProvider(): array + { + return [ + 'lower' => [5, 10], + 'equal' => [10, 10], + 'equal floats' => [1.5, 1.5], + 'mixed types' => [9.5, 10], + ]; + } + + public static function notIsLowerThanOrEqualDataProvider(): array + { + return [ + 'greater' => [10, 5], + 'greater floats' => [1.6, 1.5], + ]; + } +} diff --git a/tests/FluentAssertions/Asserts/IsNan/IsNanTest.php b/tests/FluentAssertions/Asserts/IsNan/IsNanTest.php new file mode 100644 index 0000000..6357330 --- /dev/null +++ b/tests/FluentAssertions/Asserts/IsNan/IsNanTest.php @@ -0,0 +1,45 @@ +isNan(); + + // assert + $this->correctAssertionExecuted(); + } + + #[DataProvider('notIsNanDataProvider')] + public function testNotIsNan(mixed $variable): void + { + // assert + $this->incorrectAssertionExpected(); + + // act + fact($variable)->isNan(); + } + + public static function notIsNanDataProvider(): array + { + return [ + 'float' => [1.0], + 'infinity' => [INF], + 'int' => [0], + 'string' => ['NAN'], + ]; + } +} diff --git a/tests/FluentAssertions/Asserts/IsNotZero/IsNotZeroTest.php b/tests/FluentAssertions/Asserts/IsNotZero/IsNotZeroTest.php new file mode 100644 index 0000000..b173dda --- /dev/null +++ b/tests/FluentAssertions/Asserts/IsNotZero/IsNotZeroTest.php @@ -0,0 +1,54 @@ +isNotZero(); + + // assert + $this->correctAssertionExecuted(); + } + + #[DataProvider('isZeroDataProvider')] + public function testFailsOnZero(mixed $variable): void + { + // assert + $this->incorrectAssertionExpected(); + + // act + fact($variable)->isNotZero(); + } + + public static function isNotZeroDataProvider(): array + { + return [ + 'positive int' => [1], + 'negative int' => [-1], + 'positive float' => [0.1], + ]; + } + + public static function isZeroDataProvider(): array + { + return [ + 'int zero' => [0], + 'float zero' => [0.0], + 'negative zero' => [-0.0], + ]; + } +} diff --git a/tests/FluentAssertions/Asserts/JsonPath/JsonPathTest.php b/tests/FluentAssertions/Asserts/JsonPath/JsonPathTest.php new file mode 100644 index 0000000..6fc07d2 --- /dev/null +++ b/tests/FluentAssertions/Asserts/JsonPath/JsonPathTest.php @@ -0,0 +1,62 @@ +jsonPath($path, $expected); + + // assert + $this->correctAssertionExecuted(); + } + + #[DataProvider('notMatchingDataProvider')] + public function testFailsWhenValueDiffersOrPathIsMissing(mixed $variable, string $path, mixed $expected): void + { + // assert + $this->incorrectAssertionExpected(); + + // act + fact($variable)->jsonPath($path, $expected); + } + + public static function matchingDataProvider(): array + { + return [ + 'top level key' => ['{"id":42}', 'id', 42], + 'nested key' => ['{"meta":{"total":3}}', 'meta.total', 3], + 'list position' => ['{"data":[{"id":42}]}', 'data.0.id', 42], + 'root list' => ['[10,20]', '1', 20], + 'null value' => ['{"deleted_at":null}', 'deleted_at', null], + 'nested document' => ['{"a":{"b":[1,2]}}', 'a.b', [1, 2]], + ]; + } + + public static function notMatchingDataProvider(): array + { + return [ + 'different value' => ['{"id":42}', 'id', 43], + 'strict comparison' => ['{"id":42}', 'id', '42'], + 'missing key' => ['{"id":42}', 'name', 'Ada'], + 'missing branch' => ['{"meta":{"total":3}}', 'meta.page.size', 1], + 'path into scalar' => ['{"id":42}', 'id.0', 42], + 'dotted key' => ['{"a.b":1}', 'a.b', 1], + 'invalid json' => ['not json', 'id', 42], + 'not a string' => [42, 'id', 42], + ]; + } +} diff --git a/tests/FluentAssertions/Asserts/MatchesJson/MatchesJsonTest.php b/tests/FluentAssertions/Asserts/MatchesJson/MatchesJsonTest.php index a39bbb4..e882c2c 100644 --- a/tests/FluentAssertions/Asserts/MatchesJson/MatchesJsonTest.php +++ b/tests/FluentAssertions/Asserts/MatchesJson/MatchesJsonTest.php @@ -15,23 +15,23 @@ final class MatchesJsonTest extends FluentAssertionsTestCase { #[DataProvider('matchingDataProvider')] - public function testMatchesJson(string $variable, string $expectedJson): void + public function testMatchesJson(string $variable, string|array|object $expected): void { // act - fact($variable)->matchesJson($expectedJson); + fact($variable)->matchesJson($expected); // assert $this->correctAssertionExecuted(); } #[DataProvider('notMatchingDataProvider')] - public function testFailsWhenDifferentOrInvalid(string $variable, string $expectedJson): void + public function testFailsWhenDifferentOrInvalid(string $variable, string|array|object $expected): void { // assert $this->incorrectAssertionExpected(); // act - fact($variable)->matchesJson($expectedJson); + fact($variable)->matchesJson($expected); } public static function matchingDataProvider(): array @@ -41,16 +41,20 @@ public static function matchingDataProvider(): array 'whitespace ignored' => ['{"a": 1 }', '{"a":1}'], 'nested structure' => ['{"a":{"x":[1,2]}}', '{"a":{"x":[1,2]}}'], 'scalar' => ['42', '42'], + 'expectation as array' => ['{"a":1,"b":2}', ['b' => 2, 'a' => 1]], + 'expectation as list' => ['[1,2]', [1, 2]], + 'expectation as object' => ['{"a":1}', (object) ['a' => 1]], ]; } public static function notMatchingDataProvider(): array { return [ - 'different value' => ['{"a":1}', '{"a":2}'], - 'array order' => ['[1,2]', '[2,1]'], - 'invalid actual' => ['not json', '{}'], - 'invalid expected' => ['{}', 'not json'], + 'different value' => ['{"a":1}', '{"a":2}'], + 'array order' => ['[1,2]', '[2,1]'], + 'invalid actual' => ['not json', '{}'], + 'invalid expected' => ['{}', 'not json'], + 'array expectation' => ['{"a":1}', ['a' => 2]], ]; } } diff --git a/tests/FluentAssertions/Asserts/MatchesJson/NotMatchesJsonTest.php b/tests/FluentAssertions/Asserts/MatchesJson/NotMatchesJsonTest.php index a5b96a1..33f2e66 100644 --- a/tests/FluentAssertions/Asserts/MatchesJson/NotMatchesJsonTest.php +++ b/tests/FluentAssertions/Asserts/MatchesJson/NotMatchesJsonTest.php @@ -15,30 +15,31 @@ final class NotMatchesJsonTest extends FluentAssertionsTestCase { #[DataProvider('differingDataProvider')] - public function testNotMatchesJson(string $variable, string $expectedJson): void + public function testNotMatchesJson(string $variable, string|array|object $expected): void { // act - fact($variable)->notMatchesJson($expectedJson); + fact($variable)->notMatchesJson($expected); // assert $this->correctAssertionExecuted(); } #[DataProvider('equalOrInvalidDataProvider')] - public function testFailsWhenEqualOrInvalid(string $variable, string $expectedJson): void + public function testFailsWhenEqualOrInvalid(string $variable, string|array|object $expected): void { // assert $this->incorrectAssertionExpected(); // act - fact($variable)->notMatchesJson($expectedJson); + fact($variable)->notMatchesJson($expected); } public static function differingDataProvider(): array { return [ - 'different value' => ['{"a":1}', '{"a":2}'], - 'array order' => ['[1,2]', '[2,1]'], + 'different value' => ['{"a":1}', '{"a":2}'], + 'array order' => ['[1,2]', '[2,1]'], + 'array expectation' => ['{"a":1}', ['a' => 2]], ]; } @@ -47,6 +48,7 @@ public static function equalOrInvalidDataProvider(): array return [ 'equal ignoring key order' => ['{"a":1,"b":2}', '{"b":2,"a":1}'], 'invalid actual' => ['not json', '{}'], + 'equal array expectation' => ['{"a":1}', ['a' => 1]], ]; } } diff --git a/tests/FluentAssertions/Asserts/MatchesJsonFile/MatchesJsonFileTest.php b/tests/FluentAssertions/Asserts/MatchesJsonFile/MatchesJsonFileTest.php new file mode 100644 index 0000000..c943d34 --- /dev/null +++ b/tests/FluentAssertions/Asserts/MatchesJsonFile/MatchesJsonFileTest.php @@ -0,0 +1,47 @@ +matchesJsonFile(self::fixture()); + + // assert + $this->correctAssertionExecuted(); + } + + public function testFailsWhenDocumentDiffers(): void + { + // assert + $this->incorrectAssertionExpected(); + + // act + fact('{"id":43,"name":"Ada"}')->matchesJsonFile(self::fixture()); + } + + public function testFailsWhenFileIsMissing(): void + { + // assert + $this->incorrectAssertionExpected(); + + // act + fact('{"id":42}')->matchesJsonFile(__DIR__ . '/no-such-fixture.json'); + } + + private static function fixture(): string + { + return dirname(__DIR__, 3) . '/Fixtures/expected.json'; + } +} diff --git a/tests/PHPStan/data/narrowing.php b/tests/PHPStan/data/narrowing.php index a1c6ea7..aac743e 100644 --- a/tests/PHPStan/data/narrowing.php +++ b/tests/PHPStan/data/narrowing.php @@ -141,6 +141,66 @@ function notMatchesJsonCase(mixed $value): void assertType('string', $value); } +function matchesJsonFileCase(mixed $value): void +{ + fact($value)->matchesJsonFile(__DIR__ . '/fixture.json'); + assertType('string', $value); +} + +function containsJsonCase(mixed $value): void +{ + fact($value)->containsJson(['a' => 1]); + assertType('string', $value); +} + +function notContainsJsonCase(mixed $value): void +{ + fact($value)->notContainsJson(['a' => 1]); + assertType('string', $value); +} + +function jsonPathCase(mixed $value): void +{ + fact($value)->jsonPath('a.b', 1); + assertType('string', $value); +} + +function hasJsonPathCase(mixed $value): void +{ + fact($value)->hasJsonPath('a.b'); + assertType('string', $value); +} + +function notHasJsonPathCase(mixed $value): void +{ + fact($value)->notHasJsonPath('a.b'); + assertType('string', $value); +} + +function isCloseToCase(mixed $value): void +{ + fact($value)->isCloseTo(1.0); + assertType('float|int', $value); +} + +function notCloseToCase(mixed $value): void +{ + fact($value)->notCloseTo(1.0); + assertType('float|int', $value); +} + +function isFiniteCase(mixed $value): void +{ + fact($value)->isFinite(); + assertType('float|int', $value); +} + +function isNanCase(mixed $value): void +{ + fact($value)->isNan(); + assertType('float', $value); +} + /** Unsupported assertions (equals is loose ==) must NOT narrow. */ function unsupportedDoesNotNarrow(?string $value): void { From 89f94d0f0ed7a3c9c1146061f2512d7622d323ed Mon Sep 17 00:00:00 2001 From: Nick Harin Date: Wed, 9 Sep 2026 22:49:06 +0500 Subject: [PATCH 2/4] Drop json_validate() from isJson() It is PHP 8.3+, while the package supports 8.1. --- CHANGELOG.md | 5 +++++ src/Traits/JsonAssertions.php | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0136829..9c6746c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The JSON assertions moved from `StringAssertions` into a `JsonAssertions` trait. The public API is unchanged; only code using the trait directly is affected. +### Fixed + +- `isJson()` no longer calls `json_validate()`, which only exists on PHP 8.3+ while the + package supports 8.1. It now validates the same way the other JSON assertions do. + ## [12.10.0] - 2026-07-07 ### Added diff --git a/src/Traits/JsonAssertions.php b/src/Traits/JsonAssertions.php index 4d6692f..1f20801 100644 --- a/src/Traits/JsonAssertions.php +++ b/src/Traits/JsonAssertions.php @@ -33,7 +33,9 @@ public function isJson(string $message = ''): self Assert::fail($message ?: 'Variable is not a string.'); } - Assert::assertTrue(json_validate($this->variable), $message ?: 'String is not valid JSON.'); + json_decode($this->variable); + + Assert::assertTrue(json_last_error() === JSON_ERROR_NONE, $message ?: 'String is not valid JSON.'); return $this; } From e658e573483f97b88c18c9ae90aa86359e5cb9d6 Mon Sep 17 00:00:00 2001 From: Nick Harin Date: Wed, 9 Sep 2026 22:49:06 +0500 Subject: [PATCH 3/4] Match JSON lists by membership in containsJson() Matching by index meant an expected element had to sit first, which missed the case the assertion exists for. A missing key no longer reads as null either. --- CHANGELOG.md | 4 +- README.md | 15 ++-- src/Traits/JsonAssertions.php | 80 +++++++++++++++++-- .../Asserts/ContainsJson/ContainsJsonTest.php | 30 ++++--- 4 files changed, 104 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c6746c..c1c89d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `isNotZero()`, `isFinite()` and `isNan()`. - JSON subset matching with `containsJson()` / `notContainsJson()`: only the keys named in the expectation are compared, so volatile fields in a response no longer have to be - spelled out. + spelled out. Lists are matched by membership — an expected element may sit anywhere in + the document, since an index is not the identity of an element the way a key is the + identity of a value. - JSON path assertions `jsonPath()`, `hasJsonPath()` and `notHasJsonPath()`, addressing a single value by a dot-separated path (`data.0.id`). - `matchesJsonFile()`, the fixture-file counterpart of `matchesJson()`. diff --git a/README.md b/README.md index 99b5918..3467319 100644 --- a/README.md +++ b/README.md @@ -226,20 +226,25 @@ fact('{"a":1,"b":2}')->notMatchesJson('{"b":2,"a":1}'); // Fails fact($canonicalJson)->matchesJsonFile(__DIR__ . '/fixtures/bundle.json'); // Passes when equal ``` -`containsJson()` matches a subset: every key named in the expectation must be present with -a matching value, and everything else in the document is ignored. That is what makes it -usable against responses carrying volatile fields. Nesting is walked recursively and list -positions count as keys, so `['tags' => ['a']]` matches `{"tags":["a","b"]}` but not -`{"tags":["b","a"]}`. Scalars are compared strictly. +`containsJson()` matches a subset. In an object, the keys you name must be present with a +matching value and everything else is ignored — which is what makes it usable against +responses carrying volatile fields. In a list, each expected element must match *some* +element of the document, at any position, and two expectations never claim the same one. +Scalars are compared strictly. ```php fact('{"id":42,"created_at":"2026-01-01"}')->containsJson(['id' => 42]); // Passes fact('{"data":{"id":42,"role":"admin"}}')->containsJson(['data' => ['id' => 42]]); // Passes +fact('{"items":[{"id":1},{"id":2}]}')->containsJson(['items' => [['id' => 2]]]); // Passes +fact('{"tags":["b","a"]}')->containsJson(['tags' => ['a']]); // Passes — position is not identity +fact('{"tags":["a"]}')->containsJson(['tags' => ['a', 'a']]); // Fails — only one to go around fact('{"id":42}')->containsJson(['id' => '42']); // Fails — strict comparison fact('{"id":42}')->notContainsJson(['id' => 43]); // Passes fact('{"id":42}')->notContainsJson(['id' => 42]); // Fails ``` +Element order and the exact shape of a list are `matchesJson()`'s job, not this one's. + Single values are reachable by a dot-separated path; segments address object keys and list positions alike. Keys that contain a dot are not addressable this way. ```php diff --git a/src/Traits/JsonAssertions.php b/src/Traits/JsonAssertions.php index 1f20801..55ed9a5 100644 --- a/src/Traits/JsonAssertions.php +++ b/src/Traits/JsonAssertions.php @@ -129,15 +129,16 @@ public function matchesJsonFile(string $path, string $message = ''): self /** * Asserts that a JSON document contains the expected subset. * - * Every key named in the expectation must exist in the document with a matching - * value; keys that are not named are ignored, which is what makes this usable - * against responses carrying volatile fields (`created_at`, `_links`, ...). - * Nesting is walked recursively, and list positions count as keys — so - * `['tags' => ['a']]` matches `{"tags":["a","b"]}` (index 0 is "a") but not - * `{"tags":["b","a"]}`. Scalars are compared strictly. + * In an object, every key named in the expectation must be present with a matching + * value and unnamed keys are ignored — which is what makes this usable against + * responses carrying volatile fields (`created_at`, `_links`, ...). In a list, each + * expected element must match some element of the document, at any position and + * regardless of how many others are there; two expected elements never match the + * same one. Nesting is walked recursively and scalars are compared strictly. * * Example usage: * fact('{"id":42,"created_at":"..."}')->containsJson(['id' => 42]); // Passes + * fact('{"items":[{"id":1},{"id":2}]}')->containsJson(['items' => [['id' => 2]]]); // Passes * fact('{"id":42}')->containsJson(['id' => 43]); // Fails * * @param string|array|object $expected The expected subset, as JSON text or as a value to encode. @@ -150,7 +151,7 @@ public function containsJson(string|array|object $expected, string $message = '' [$document, $subset] = $this->decodeJsonSubsetPair($expected, $message); Assert::assertTrue( - $this->arrayContainsAssociativeArrayRecursive($document, $subset), + $this->jsonContainsSubset($document, $subset), $message ?: sprintf( "JSON document does not contain the expected subset.\n\nDocument: %s\n\nExpected subset: %s", self::encodeJsonForMessage($document), @@ -180,7 +181,7 @@ public function notContainsJson(string|array|object $expected, string $message = [$document, $subset] = $this->decodeJsonSubsetPair($expected, $message); Assert::assertFalse( - $this->arrayContainsAssociativeArrayRecursive($document, $subset), + $this->jsonContainsSubset($document, $subset), $message ?: sprintf( "JSON document contains the subset it should not.\n\nDocument: %s\n\nUnexpected subset: %s", self::encodeJsonForMessage($document), @@ -339,6 +340,69 @@ private function decodeJsonSubsetPair(string|array|object $expected, string $mes return [$document, $subset]; } + /** + * Matches an expected subset against a decoded document. + * + * An object keeps subset semantics (unnamed keys are ignored); a list is matched by + * membership rather than by position, because an index is not the identity of an + * element the way a key is the identity of a value. + * + * @param array $document + * @param array $subset + */ + private function jsonContainsSubset(array $document, array $subset): bool + { + if (array_is_list($document) && array_is_list($subset)) { + return $this->jsonListContainsAll($document, $subset, 0, []); + } + + foreach ($subset as $key => $value) { + if (! array_key_exists($key, $document) || ! $this->jsonValueMatches($document[$key], $value)) { + return false; + } + } + + return true; + } + + private function jsonValueMatches(mixed $documentValue, mixed $expected): bool + { + if (is_array($documentValue) && is_array($expected)) { + return $this->jsonContainsSubset($documentValue, $expected); + } + + return $documentValue === $expected; + } + + /** + * Pairs every expected element with a distinct document element. + * + * Backtracks rather than taking the first match: with subsets on both sides a greedy + * pass can consume the only element a later expectation could have matched. + * + * @param array $document + * @param array $expected + * @param array $taken + */ + private function jsonListContainsAll(array $document, array $expected, int $index, array $taken): bool + { + if (! isset($expected[$index])) { + return true; + } + + foreach ($document as $position => $candidate) { + if (isset($taken[$position]) || ! $this->jsonValueMatches($candidate, $expected[$index])) { + continue; + } + + if ($this->jsonListContainsAll($document, $expected, $index + 1, $taken + [$position => true])) { + return true; + } + } + + return false; + } + /** * Walks a dot-separated path. * diff --git a/tests/FluentAssertions/Asserts/ContainsJson/ContainsJsonTest.php b/tests/FluentAssertions/Asserts/ContainsJson/ContainsJsonTest.php index fa37eec..1a24372 100644 --- a/tests/FluentAssertions/Asserts/ContainsJson/ContainsJsonTest.php +++ b/tests/FluentAssertions/Asserts/ContainsJson/ContainsJsonTest.php @@ -40,7 +40,12 @@ public static function containingDataProvider(): array 'volatile fields ignored' => ['{"id":42,"created_at":"2026-01-01"}', ['id' => 42]], 'expectation as json' => ['{"id":42,"name":"Ada"}', '{"id":42}'], 'nested subset' => ['{"data":{"id":42,"role":"admin"}}', ['data' => ['id' => 42]]], - 'list prefix' => ['{"tags":["a","b","c"]}', ['tags' => ['a', 'b']]], + 'list member' => ['{"tags":["a","b","c"]}', ['tags' => ['b']]], + 'list member reordered' => ['{"tags":["b","a"]}', ['tags' => ['a']]], + 'object inside a list' => ['{"items":[{"id":1},{"id":2}]}', ['items' => [['id' => 2]]]], + 'subset of a list element' => ['{"items":[{"id":1,"role":"admin"}]}', ['items' => [['id' => 1]]]], + 'repeated element' => ['{"tags":["a","a"]}', ['tags' => ['a', 'a']]], + 'greedy pairing avoided' => ['{"i":[{"id":1,"n":"a"},{"id":1}]}', ['i' => [['id' => 1], ['id' => 1, 'n' => 'a']]]], 'empty subset' => ['{"id":42}', []], 'null value' => ['{"deleted_at":null}', ['deleted_at' => null]], ]; @@ -49,16 +54,19 @@ public static function containingDataProvider(): array public static function notContainingDataProvider(): array { return [ - 'different value' => ['{"id":42}', ['id' => 43]], - 'loose comparison' => ['{"id":42}', ['id' => '42']], - 'missing key' => ['{"id":42}', ['name' => 'Ada']], - 'list order' => ['{"tags":["a","b"]}', ['tags' => ['b']]], - 'nested mismatch' => ['{"data":{"id":42}}', ['data' => ['id' => 43]]], - 'invalid actual' => ['not json', ['id' => 42]], - 'invalid expected' => ['{"id":42}', 'not json'], - 'scalar document' => ['42', ['id' => 42]], - 'scalar subset' => ['{"id":42}', '42'], - 'subject not string' => [42, ['id' => 42]], + 'different value' => ['{"id":42}', ['id' => 43]], + 'loose comparison' => ['{"id":42}', ['id' => '42']], + 'missing key' => ['{"id":42}', ['name' => 'Ada']], + 'missing key expecting null' => ['{"id":42}', ['deleted_at' => null]], + 'absent list member' => ['{"tags":["a","b"]}', ['tags' => ['c']]], + 'more elements than present' => ['{"tags":["a"]}', ['tags' => ['a', 'a']]], + 'no element matches' => ['{"items":[{"id":1}]}', ['items' => [['id' => 2]]]], + 'nested mismatch' => ['{"data":{"id":42}}', ['data' => ['id' => 43]]], + 'invalid actual' => ['not json', ['id' => 42]], + 'invalid expected' => ['{"id":42}', 'not json'], + 'scalar document' => ['42', ['id' => 42]], + 'scalar subset' => ['{"id":42}', '42'], + 'subject not string' => [42, ['id' => 42]], ]; } } From c73d1834432da95e6c2e9d4eb70fee091799948b Mon Sep 17 00:00:00 2001 From: Nick Harin Date: Wed, 9 Sep 2026 22:49:06 +0500 Subject: [PATCH 4/4] Use one subset rule for arrays and JSON arrayContainsAssociativeArray() had the same two bugs; both share SubsetMatcher now. --- CHANGELOG.md | 6 ++ README.md | 4 + src/Support/SubsetMatcher.php | 78 +++++++++++++++++++ src/Traits/ArrayAssertions.php | 30 ++----- src/Traits/JsonAssertions.php | 68 +--------------- .../ArrayContainsAssociativeArrayTest.php | 15 +++- .../Asserts/ContainsJson/ContainsJsonTest.php | 2 + 7 files changed, 112 insertions(+), 91 deletions(-) create mode 100644 src/Support/SubsetMatcher.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c1c89d4..47e5727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,12 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed +- `arrayContainsAssociativeArray()` now answers the subset question the same way + `containsJson()` does. Inside a list an expected element may sit at any position instead + of having to line up by index, so assertions that used to fail on reordered data now + pass. It also tells a missing key apart from a key holding `null`: expecting + `['parent' => null]` against `['id' => 1]` used to pass and now fails, which turns tests + that were asserting nothing red. - The JSON assertions moved from `StringAssertions` into a `JsonAssertions` trait. The public API is unchanged; only code using the trait directly is affected. diff --git a/README.md b/README.md index 3467319..e624ba3 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,10 @@ fact([1, 2, 3])->notCount(3); // Fails fact(['a' => ['b' => 'c']])->arrayContainsAssociativeArray(['a' => ['b' => 'c']]); // Passes fact(['a' => ['b' => 'd']])->arrayContainsAssociativeArray(['a' => ['b' => 'c']]); // Fails +fact(['tags' => ['a', 'b']])->arrayContainsAssociativeArray(['tags' => ['b']]); // Passes — position does not matter +fact(['tags' => ['a']])->arrayContainsAssociativeArray(['tags' => ['a', 'a']]); // Fails — only one to go around +fact(['id' => 1])->arrayContainsAssociativeArray(['parent' => null]); // Fails — there is no such key + fact(['a' => 1])->arrayHasKey('a'); // Passes fact(['a' => 1])->arrayHasKey('b'); // Fails diff --git a/src/Support/SubsetMatcher.php b/src/Support/SubsetMatcher.php new file mode 100644 index 0000000..ddcb02d --- /dev/null +++ b/src/Support/SubsetMatcher.php @@ -0,0 +1,78 @@ + $document + * @param array $subset + */ + public static function matches(array $document, array $subset): bool + { + if (array_is_list($document) && array_is_list($subset)) { + return self::listMatches($document, $subset, 0, []); + } + + foreach ($subset as $key => $value) { + if (! array_key_exists($key, $document) || ! self::valueMatches($document[$key], $value)) { + return false; + } + } + + return true; + } + + private static function valueMatches(mixed $documentValue, mixed $expected): bool + { + if (is_array($documentValue) && is_array($expected)) { + return self::matches($documentValue, $expected); + } + + return $documentValue === $expected; + } + + /** + * Pairs every expected element with a distinct document element. + * + * Backtracks rather than keeping the first match: with subsets on both sides a greedy + * pass can consume the only element a later expectation could have matched. + * + * @param list $document + * @param list $expected + * @param array $taken + */ + private static function listMatches(array $document, array $expected, int $index, array $taken): bool + { + if ($index === count($expected)) { + return true; + } + + foreach ($document as $position => $candidate) { + if (isset($taken[$position]) || ! self::valueMatches($candidate, $expected[$index])) { + continue; + } + + if (self::listMatches($document, $expected, $index + 1, $taken + [$position => true])) { + return true; + } + } + + return false; + } +} diff --git a/src/Traits/ArrayAssertions.php b/src/Traits/ArrayAssertions.php index 704d0b0..63b2c18 100644 --- a/src/Traits/ArrayAssertions.php +++ b/src/Traits/ArrayAssertions.php @@ -5,6 +5,7 @@ namespace K2gl\PHPUnitFluentAssertions\Traits; use K2gl\PHPUnitFluentAssertions\FluentAssertions; +use K2gl\PHPUnitFluentAssertions\Support\SubsetMatcher; use PHPUnit\Framework\Assert; use ArrayAccess; use Countable; @@ -67,11 +68,15 @@ public function notCount(int $elementsCount, string $message = ''): self /** * Asserts that the array contains another associative array. * - * This method checks if the actual array contains all the key-value pairs from the provided array. + * The keys named in the expectation must be present with a matching value; unnamed keys + * are ignored. Inside a list the expectation is matched by membership, so an expected + * element may sit at any position, and two expectations never claim the same element. * * Example usage: * fact(['a' => ['b' => 'c']])->arrayContainsAssociativeArray(['a' => ['b' => 'c']]); // Passes + * fact(['tags' => ['a', 'b']])->arrayContainsAssociativeArray(['tags' => ['b']]); // Passes * fact(['a' => ['b' => 'd']])->arrayContainsAssociativeArray(['a' => ['b' => 'c']]); // Fails + * fact(['id' => 1])->arrayContainsAssociativeArray(['parent' => null]); // Fails — no such key * * @param array $values The associative array that should be contained within the actual array. * @@ -84,7 +89,7 @@ public function arrayContainsAssociativeArray(array $values): self } Assert::assertTrue( - $this->arrayContainsAssociativeArrayRecursive($this->variable, $values), + SubsetMatcher::matches($this->variable, $values), sprintf( "Array does not contain associative array. \n\nArray: '%s' \n\nExpected values: '%s'", var_export($this->variable, true), @@ -95,27 +100,6 @@ public function arrayContainsAssociativeArray(array $values): self return $this; } - /** - * @param array $data - * @param array $values - */ - protected function arrayContainsAssociativeArrayRecursive(array $data, array $values): bool - { - foreach ($values as $key => $value) { - $actual = $data[$key] ?? null; - - if (is_array($value)) { - if (! is_array($actual) || ! $this->arrayContainsAssociativeArrayRecursive($actual, $value)) { - return false; - } - } elseif ($actual !== $value) { - return false; - } - } - - return true; - } - /** * Asserts that the variable has a specific key. * diff --git a/src/Traits/JsonAssertions.php b/src/Traits/JsonAssertions.php index 55ed9a5..f5073be 100644 --- a/src/Traits/JsonAssertions.php +++ b/src/Traits/JsonAssertions.php @@ -5,6 +5,7 @@ namespace K2gl\PHPUnitFluentAssertions\Traits; use K2gl\PHPUnitFluentAssertions\FluentAssertions; +use K2gl\PHPUnitFluentAssertions\Support\SubsetMatcher; use PHPUnit\Framework\Assert; /** @@ -151,7 +152,7 @@ public function containsJson(string|array|object $expected, string $message = '' [$document, $subset] = $this->decodeJsonSubsetPair($expected, $message); Assert::assertTrue( - $this->jsonContainsSubset($document, $subset), + SubsetMatcher::matches($document, $subset), $message ?: sprintf( "JSON document does not contain the expected subset.\n\nDocument: %s\n\nExpected subset: %s", self::encodeJsonForMessage($document), @@ -181,7 +182,7 @@ public function notContainsJson(string|array|object $expected, string $message = [$document, $subset] = $this->decodeJsonSubsetPair($expected, $message); Assert::assertFalse( - $this->jsonContainsSubset($document, $subset), + SubsetMatcher::matches($document, $subset), $message ?: sprintf( "JSON document contains the subset it should not.\n\nDocument: %s\n\nUnexpected subset: %s", self::encodeJsonForMessage($document), @@ -340,69 +341,6 @@ private function decodeJsonSubsetPair(string|array|object $expected, string $mes return [$document, $subset]; } - /** - * Matches an expected subset against a decoded document. - * - * An object keeps subset semantics (unnamed keys are ignored); a list is matched by - * membership rather than by position, because an index is not the identity of an - * element the way a key is the identity of a value. - * - * @param array $document - * @param array $subset - */ - private function jsonContainsSubset(array $document, array $subset): bool - { - if (array_is_list($document) && array_is_list($subset)) { - return $this->jsonListContainsAll($document, $subset, 0, []); - } - - foreach ($subset as $key => $value) { - if (! array_key_exists($key, $document) || ! $this->jsonValueMatches($document[$key], $value)) { - return false; - } - } - - return true; - } - - private function jsonValueMatches(mixed $documentValue, mixed $expected): bool - { - if (is_array($documentValue) && is_array($expected)) { - return $this->jsonContainsSubset($documentValue, $expected); - } - - return $documentValue === $expected; - } - - /** - * Pairs every expected element with a distinct document element. - * - * Backtracks rather than taking the first match: with subsets on both sides a greedy - * pass can consume the only element a later expectation could have matched. - * - * @param array $document - * @param array $expected - * @param array $taken - */ - private function jsonListContainsAll(array $document, array $expected, int $index, array $taken): bool - { - if (! isset($expected[$index])) { - return true; - } - - foreach ($document as $position => $candidate) { - if (isset($taken[$position]) || ! $this->jsonValueMatches($candidate, $expected[$index])) { - continue; - } - - if ($this->jsonListContainsAll($document, $expected, $index + 1, $taken + [$position => true])) { - return true; - } - } - - return false; - } - /** * Walks a dot-separated path. * diff --git a/tests/FluentAssertions/Asserts/ArrayContainsAssociativeArray/ArrayContainsAssociativeArrayTest.php b/tests/FluentAssertions/Asserts/ArrayContainsAssociativeArray/ArrayContainsAssociativeArrayTest.php index 73de9e2..3dde231 100644 --- a/tests/FluentAssertions/Asserts/ArrayContainsAssociativeArray/ArrayContainsAssociativeArrayTest.php +++ b/tests/FluentAssertions/Asserts/ArrayContainsAssociativeArray/ArrayContainsAssociativeArrayTest.php @@ -15,7 +15,7 @@ final class ArrayContainsAssociativeArrayTest extends FluentAssertionsTestCase { #[DataProvider('arrayContainsDataProvider')] - public function testArrayContains1111(array $data, array $values): void + public function testArrayContainsAssociativeArray(array $data, array $values): void { // act fact($data)->arrayContainsAssociativeArray($values); @@ -35,6 +35,11 @@ public static function arrayContainsDataProvider(): array ['data' => ['items' => ['one', 'two']], 'values' => ['items' => ['one', 'two']]], ['data' => ['items' => ['one', 'two', 'three']], 'values' => ['items' => ['one', 'two']]], ['data' => ['items' => ['one', 'two', 'three']], 'values' => ['items' => ['one', 'two', 'three']]], + ['data' => ['items' => ['one', 'two']], 'values' => ['items' => ['two']]], + ['data' => ['items' => ['two', 'one']], 'values' => ['items' => ['one', 'two']]], + ['data' => ['items' => [['id' => 1], ['id' => 2]]], 'values' => ['items' => [['id' => 2]]]], + ['data' => ['items' => [null, 'one']], 'values' => ['items' => [null]]], + ['data' => ['parent' => null], 'values' => ['parent' => null]], [ 'data' => ['a' => ['1', '2' => ['00' => '00', '11' => '111', '22' => '222'], '3']], 'values' => ['a' => ['2' => ['11' => '111']]], @@ -47,7 +52,7 @@ public static function arrayContainsDataProvider(): array } #[DataProvider('arrayNotContainsDataProvider')] - public function testArrayNotContains2222(mixed $data, mixed $values): void + public function testFailsWhenValuesAreAbsent(mixed $data, mixed $values): void { // assert $this->incorrectAssertionExpected(); @@ -60,7 +65,11 @@ public static function arrayNotContainsDataProvider(): array { return [ ['data' => ['one' => 'two'], 'values' => ['one' => 'three']], - ['data' => ['items' => ['one', 'two']], 'values' => ['items' => ['two', 'one']]], + ['data' => ['items' => ['one', 'two']], 'values' => ['items' => ['three']]], + ['data' => ['items' => ['one']], 'values' => ['items' => ['one', 'one']]], + ['data' => ['items' => ['one']], 'values' => ['items' => [null]]], + ['data' => ['id' => 1], 'values' => ['parent' => null]], + ['data' => ['id' => 1, 'parnet' => null], 'values' => ['parent' => null]], [ 'data' => ['a' => ['type' => 'candy', 'color' => 'red'], 'b' => ['miss' => 'kiss', 'foo' => 'bar']], 'values' => ['b' => ['foo' => 'bar', 'miss' => 'kiss'], 'a' => ['color' => 'green']], diff --git a/tests/FluentAssertions/Asserts/ContainsJson/ContainsJsonTest.php b/tests/FluentAssertions/Asserts/ContainsJson/ContainsJsonTest.php index 1a24372..472a4eb 100644 --- a/tests/FluentAssertions/Asserts/ContainsJson/ContainsJsonTest.php +++ b/tests/FluentAssertions/Asserts/ContainsJson/ContainsJsonTest.php @@ -46,6 +46,7 @@ public static function containingDataProvider(): array 'subset of a list element' => ['{"items":[{"id":1,"role":"admin"}]}', ['items' => [['id' => 1]]]], 'repeated element' => ['{"tags":["a","a"]}', ['tags' => ['a', 'a']]], 'greedy pairing avoided' => ['{"i":[{"id":1,"n":"a"},{"id":1}]}', ['i' => [['id' => 1], ['id' => 1, 'n' => 'a']]]], + 'null inside a list' => ['{"tags":[null,"a"]}', ['tags' => [null]]], 'empty subset' => ['{"id":42}', []], 'null value' => ['{"deleted_at":null}', ['deleted_at' => null]], ]; @@ -59,6 +60,7 @@ public static function notContainingDataProvider(): array 'missing key' => ['{"id":42}', ['name' => 'Ada']], 'missing key expecting null' => ['{"id":42}', ['deleted_at' => null]], 'absent list member' => ['{"tags":["a","b"]}', ['tags' => ['c']]], + 'null absent from list' => ['{"tags":["a"]}', ['tags' => [null]]], 'more elements than present' => ['{"tags":["a"]}', ['tags' => ['a', 'a']]], 'no element matches' => ['{"items":[{"id":1}]}', ['items' => [['id' => 2]]]], 'nested mismatch' => ['{"data":{"id":42}}', ['data' => ['id' => 43]]],