Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions src/Mixins/Expectation.php
Original file line number Diff line number Diff line change
Expand Up @@ -1187,4 +1187,151 @@ public function toBeSlug(string $message = ''): self

return $this;
}

/**
* Asserts that the array is sorted in ascending order.
*
* @return self<TValue>
*/
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<TValue>
*/
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<TValue>
*/
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<mixed> $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.');
}
}
}
}
52 changes: 52 additions & 0 deletions tests/Features/Expect/toBeAscending.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

use Pest\Exceptions\InvalidExpectationValue;
use PHPUnit\Framework\ExpectationFailedException;

test('pass with integers', function () {
expect([1, 2, 3])->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);
52 changes: 52 additions & 0 deletions tests/Features/Expect/toBeDescending.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

use Pest\Exceptions\InvalidExpectationValue;
use PHPUnit\Framework\ExpectationFailedException;

test('pass with integers', function () {
expect([3, 2, 1])->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);
108 changes: 108 additions & 0 deletions tests/Features/Expect/toBeSorted.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

use Pest\Exceptions\InvalidExpectationValue;
use PHPUnit\Framework\ExpectationFailedException;

test('pass with no $by delegates to ascending', function () {
expect([1, 2, 3])->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);