diff --git a/src/Mixins/Expectation.php b/src/Mixins/Expectation.php index 0a7329a94..95866ac65 100644 --- a/src/Mixins/Expectation.php +++ b/src/Mixins/Expectation.php @@ -1187,4 +1187,151 @@ public function toBeSlug(string $message = ''): self return $this; } + + /** + * Asserts that the array is sorted in ascending order. + * + * @return self + */ + public function toBeAscending(string $message = ''): self + { + if (! is_array($this->value)) { + InvalidExpectationValue::expected('array'); + } + + $values = array_values($this->value); + $sorted = true; + + if (count($values) > 1) { + $this->assertHomogeneousComparableArray($values); + + for ($i = 0, $max = count($values) - 1; $i < $max; $i++) { + if ($values[$i] > $values[$i + 1]) { + $sorted = false; + break; + } + } + } + + Assert::assertTrue($sorted, $message !== '' ? $message : 'Array is not sorted in ascending order.'); + + return $this; + } + + /** + * Asserts that the array is sorted in descending order. + * + * @return self + */ + public function toBeDescending(string $message = ''): self + { + if (! is_array($this->value)) { + InvalidExpectationValue::expected('array'); + } + + $values = array_values($this->value); + $sorted = true; + + if (count($values) > 1) { + $this->assertHomogeneousComparableArray($values); + + for ($i = 0, $max = count($values) - 1; $i < $max; $i++) { + if ($values[$i] < $values[$i + 1]) { + $sorted = false; + break; + } + } + } + + Assert::assertTrue($sorted, $message !== '' ? $message : 'Array is not sorted in descending order.'); + + return $this; + } + + /** + * Asserts that the array is sorted by an optional key or property. + * + * @return self + */ + public function toBeSorted(?string $by = null, string $direction = 'asc', string $message = ''): self + { + if (! is_array($this->value)) { + InvalidExpectationValue::expected('array'); + } + + if ($direction !== 'asc' && $direction !== 'desc') { + throw new InvalidArgumentException(sprintf('Direction must be "asc" or "desc", got "%s".', $direction)); + } + + if ($by === null) { + return $direction === 'asc' + ? $this->toBeAscending($message) + : $this->toBeDescending($message); + } + + $extracted = []; + foreach ($this->value as $item) { + if (is_array($item)) { + if (! array_key_exists($by, $item)) { + throw new InvalidArgumentException(sprintf('Array key [%s] does not exist.', $by)); + } + $extracted[] = $item[$by]; + } elseif (is_object($item)) { + if (! property_exists($item, $by)) { + throw new InvalidArgumentException(sprintf('Property [%s] does not exist.', $by)); + } + $extracted[] = $item->{$by}; + } else { + throw new InvalidArgumentException(sprintf('Cannot extract key [%s] from non-array, non-object value.', $by)); + } + } + + $dirLabel = $direction === 'asc' ? 'ascending' : 'descending'; + $defaultMessage = "Array is not sorted by [{$by}] in {$dirLabel} order."; + $sorted = true; + + if (count($extracted) > 1) { + $this->assertHomogeneousComparableArray($extracted); + + for ($i = 0, $max = count($extracted) - 1; $i < $max; $i++) { + $failed = $direction === 'asc' + ? $extracted[$i] > $extracted[$i + 1] + : $extracted[$i] < $extracted[$i + 1]; + if ($failed) { + $sorted = false; + break; + } + } + } + + Assert::assertTrue($sorted, $message !== '' ? $message : $defaultMessage); + + return $this; + } + + /** + * @param array $values + */ + private function assertHomogeneousComparableArray(array $values): void + { + $typeGroup = static function (mixed $v): string { + if (is_int($v) || is_float($v)) { + return 'numeric'; + } + if (is_string($v)) { + return 'string'; + } + if ($v instanceof DateTimeInterface) { + return 'datetime'; + } + throw new InvalidArgumentException(sprintf('Array values must be int, float, string, or DateTimeInterface. Got [%s].', get_debug_type($v))); + }; + + $first = $typeGroup($values[0]); + foreach (array_slice($values, 1) as $v) { + if ($typeGroup($v) !== $first) { + throw new InvalidArgumentException('Array values must all be of the same comparable type.'); + } + } + } } diff --git a/tests/Features/Expect/toBeAscending.php b/tests/Features/Expect/toBeAscending.php new file mode 100644 index 000000000..bfeccbdd3 --- /dev/null +++ b/tests/Features/Expect/toBeAscending.php @@ -0,0 +1,52 @@ +toBeAscending(); +}); + +test('pass with equal adjacent values', function () { + expect([1, 2, 2, 3])->toBeAscending(); +}); + +test('pass with strings', function () { + expect(['apple', 'banana', 'cherry'])->toBeAscending(); +}); + +test('pass with floats', function () { + expect([1.1, 2.2, 3.3])->toBeAscending(); +}); + +test('pass with single element', function () { + expect([42])->toBeAscending(); +}); + +test('pass with empty array', function () { + expect([])->toBeAscending(); +}); + +test('failures', function () { + expect([3, 1, 2])->toBeAscending(); +})->throws(ExpectationFailedException::class, 'Array is not sorted in ascending order.'); + +test('failures with custom message', function () { + expect([3, 1, 2])->toBeAscending('oh no!'); +})->throws(ExpectationFailedException::class, 'oh no!'); + +test('failures with invalid type', function () { + expect('not an array')->toBeAscending(); +})->throws(InvalidExpectationValue::class, 'Invalid expectation value type. Expected [array]'); + +test('failures with mixed types', function () { + expect([1, 'two', 3])->toBeAscending(); +})->throws(InvalidArgumentException::class, 'Array values must all be of the same comparable type.'); + +test('not pass', function () { + expect([3, 1, 2])->not->toBeAscending(); +}); + +test('not failures', function () { + expect([1, 2, 3])->not->toBeAscending(); +})->throws(ExpectationFailedException::class); diff --git a/tests/Features/Expect/toBeDescending.php b/tests/Features/Expect/toBeDescending.php new file mode 100644 index 000000000..0efac5e5d --- /dev/null +++ b/tests/Features/Expect/toBeDescending.php @@ -0,0 +1,52 @@ +toBeDescending(); +}); + +test('pass with equal adjacent values', function () { + expect([3, 2, 2, 1])->toBeDescending(); +}); + +test('pass with strings', function () { + expect(['cherry', 'banana', 'apple'])->toBeDescending(); +}); + +test('pass with floats', function () { + expect([3.3, 2.2, 1.1])->toBeDescending(); +}); + +test('pass with single element', function () { + expect([42])->toBeDescending(); +}); + +test('pass with empty array', function () { + expect([])->toBeDescending(); +}); + +test('failures', function () { + expect([1, 2, 3])->toBeDescending(); +})->throws(ExpectationFailedException::class, 'Array is not sorted in descending order.'); + +test('failures with custom message', function () { + expect([1, 2, 3])->toBeDescending('oh no!'); +})->throws(ExpectationFailedException::class, 'oh no!'); + +test('failures with invalid type', function () { + expect('not an array')->toBeDescending(); +})->throws(InvalidExpectationValue::class, 'Invalid expectation value type. Expected [array]'); + +test('failures with mixed types', function () { + expect([3, 'two', 1])->toBeDescending(); +})->throws(InvalidArgumentException::class, 'Array values must all be of the same comparable type.'); + +test('not pass', function () { + expect([1, 2, 3])->not->toBeDescending(); +}); + +test('not failures', function () { + expect([3, 2, 1])->not->toBeDescending(); +})->throws(ExpectationFailedException::class); diff --git a/tests/Features/Expect/toBeSorted.php b/tests/Features/Expect/toBeSorted.php new file mode 100644 index 000000000..33d825cfa --- /dev/null +++ b/tests/Features/Expect/toBeSorted.php @@ -0,0 +1,108 @@ +toBeSorted(); +}); + +test('pass with no $by delegates to descending', function () { + expect([3, 2, 1])->toBeSorted(direction: 'desc'); +}); + +test('pass with $by on associative arrays ascending', function () { + $users = [ + ['name' => 'Anna', 'age' => 20], + ['name' => 'Ben', 'age' => 25], + ['name' => 'Cara', 'age' => 30], + ]; + expect($users)->toBeSorted(by: 'age'); +}); + +test('pass with $by on associative arrays descending', function () { + $users = [ + ['name' => 'Cara', 'age' => 30], + ['name' => 'Ben', 'age' => 25], + ['name' => 'Anna', 'age' => 20], + ]; + expect($users)->toBeSorted(by: 'age', direction: 'desc'); +}); + +test('pass with $by on object properties', function () { + $users = [ + (object) ['name' => 'Anna'], + (object) ['name' => 'Ben'], + (object) ['name' => 'Cara'], + ]; + expect($users)->toBeSorted(by: 'name'); +}); + +test('pass with $by on DateTime values', function () { + $posts = [ + ['created_at' => new DateTime('2024-01-01')], + ['created_at' => new DateTime('2024-06-01')], + ['created_at' => new DateTime('2024-12-01')], + ]; + expect($posts)->toBeSorted(by: 'created_at'); +}); + +test('pass with single element', function () { + expect([['age' => 25]])->toBeSorted(by: 'age'); +}); + +test('pass with empty array', function () { + expect([])->toBeSorted(by: 'age'); +}); + +test('failures with $by', function () { + $users = [ + ['age' => 30], + ['age' => 20], + ]; + expect($users)->toBeSorted(by: 'age'); +})->throws(ExpectationFailedException::class, 'Array is not sorted by [age] in ascending order.'); + +test('failures with $by descending', function () { + $users = [ + ['age' => 20], + ['age' => 30], + ]; + expect($users)->toBeSorted(by: 'age', direction: 'desc'); +})->throws(ExpectationFailedException::class, 'Array is not sorted by [age] in descending order.'); + +test('failures with custom message', function () { + expect([['age' => 30], ['age' => 20]])->toBeSorted(by: 'age', message: 'oh no!'); +})->throws(ExpectationFailedException::class, 'oh no!'); + +test('failures with invalid direction', function () { + expect([1, 2, 3])->toBeSorted(direction: 'sideways'); +})->throws(InvalidArgumentException::class, 'Direction must be "asc" or "desc", got "sideways".'); + +test('failures with invalid type', function () { + expect('not an array')->toBeSorted(); +})->throws(InvalidExpectationValue::class, 'Invalid expectation value type. Expected [array]'); + +test('failures with missing array key', function () { + expect([['name' => 'Anna']])->toBeSorted(by: 'age'); +})->throws(InvalidArgumentException::class, 'Array key [age] does not exist.'); + +test('failures with missing object property', function () { + expect([(object) ['name' => 'Anna']])->toBeSorted(by: 'age'); +})->throws(InvalidArgumentException::class, 'Property [age] does not exist.'); + +test('not pass', function () { + $users = [ + ['age' => 30], + ['age' => 20], + ]; + expect($users)->not->toBeSorted(by: 'age'); +}); + +test('not failures', function () { + $users = [ + ['age' => 20], + ['age' => 30], + ]; + expect($users)->not->toBeSorted(by: 'age'); +})->throws(ExpectationFailedException::class);