diff --git a/README.md b/README.md
index 8804001..b96abea 100644
--- a/README.md
+++ b/README.md
@@ -67,7 +67,7 @@ use TinyBlocks\Collection\Order;
use TinyBlocks\Mapper\KeyPreservation;
$collection = Collection::createFrom(elements: [1, 2, 3, 4, 5])
- ->add(elements: [6, 7])
+ ->add(6, 7)
->filter(predicates: static fn(int $value): bool => $value > 3)
->sort(order: Order::ASCENDING_VALUE)
->map(transformations: static fn(int $value): int => $value * 2)
@@ -76,6 +76,31 @@ $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5])
# Output: [8, 10, 12, 14]
```
+### Extending Collection
+
+Domain collections should extend the `Collection` class to inherit all collection behavior:
+
+```php
+reduce(
+ accumulator: static fn(float $carry, Invoice $invoice): float => $carry + $invoice->amount,
+ initial: 0.0
+ );
+ }
+}
+```
+
### Writing
@@ -84,10 +109,10 @@ These methods enable adding, removing, and modifying elements in the Collection.
#### Adding elements
-- `add`: Adds one or more elements to the Collection.
+- `add`: Returns a new collection with the specified elements appended.
```php
- $collection->add(elements: [1, 2, 3]);
+ $collection->add(1, 2, 3);
```
```php
@@ -96,8 +121,7 @@ These methods enable adding, removing, and modifying elements in the Collection.
#### Merging collections
-- `merge`: Merges the elements of another Collectible into the current Collection lazily, without materializing either
- collection.
+- `merge`: Merges the elements of another Collectible into the current Collection.
```php
$collectionA->merge(other: $collectionB);
@@ -105,27 +129,27 @@ These methods enable adding, removing, and modifying elements in the Collection.
#### Removing elements
-- `remove`: Removes a specific element from the Collection.
+- `remove`: Returns a new collection with all occurrences of the specified element removed.
```php
$collection->remove(element: 1);
```
-- `removeAll`: Removes elements from the Collection.
+- `removeAll`: Returns a new collection with elements removed.
- - **With a filter**: Removes only the elements that match the provided filter.
+ - **With a predicate**: Removes only the elements that satisfy the given predicate.
```php
- $collection->removeAll(filter: static fn(Amount $amount): bool => $amount->value > 10.0);
+ $collection->removeAll(predicate: static fn(Amount $amount): bool => $amount->value > 10.0);
```
- - **Without a filter**: Removes all elements from the Collection.
+ - **Without a predicate**: Removes all elements from the Collection.
```php
$collection->removeAll();
```
-
+
### Filtering
@@ -133,16 +157,16 @@ These methods enable filtering elements in the Collection based on specific cond
#### Filter by predicate
-- `filter`: Filters elements in the Collection.
+- `filter`: Retains only elements satisfying all given predicates.
- - **With predicates**: Filter elements are based on the provided predicates.
+ - **With predicates**: Retains elements that satisfy the provided predicates.
```php
$collection->filter(predicates: static fn(Amount $amount): bool => $amount->value > 100);
```
- - **Without predicates**: Removes all empty or false values (e.g., `null`, `false`, empty arrays).
+ - **Without predicates**: Removes all falsy values (e.g., `null`, `false`, `0`, `''`, empty arrays).
```php
$collection->filter();
@@ -152,11 +176,11 @@ These methods enable filtering elements in the Collection based on specific cond
### Ordering
-These methods enable sorting elements in the Collection based on the specified order and optional predicates.
+These methods enable sorting elements in the Collection based on the specified order and optional comparator.
-#### Sort by order and custom predicate
+#### Sort by order and custom comparator
-- `sort`: Sorts the Collection.
+- `sort`: Returns a new sorted collection.
```
Order::ASCENDING_KEY: Sorts the collection in ascending order by key.
@@ -173,13 +197,15 @@ These methods enable sorting elements in the Collection based on the specified o
$collection->sort(order: Order::DESCENDING_VALUE);
```
- Sort the Collection using a custom predicate to determine how elements should be
- compared.
+ Sort the Collection using a custom comparator to determine how elements should be compared.
```php
use TinyBlocks\Collection\Order;
- $collection->sort(order: Order::ASCENDING_VALUE, predicate: static fn(Amount $amount): float => $amount->value);
+ $collection->sort(
+ order: Order::ASCENDING_VALUE,
+ comparator: static fn(Amount $first, Amount $second): int => $first->value <=> $second->value
+ );
```
@@ -197,54 +223,63 @@ elements, or finding elements that match a specific condition.
$collection->count();
```
+#### Check if empty
+
+- `isEmpty`: Determines whether the collection has no elements.
+
+ ```php
+ $collection->isEmpty();
+ ```
+
#### Retrieve by condition
-- `findBy`: Finds the first element that matches one or more predicates.
+- `findBy`: Finds the first element that satisfies any given predicate, or returns `null` if no predicate matches.
+ When called without predicates, it returns `null`.
```php
$collection->findBy(predicates: static fn(CryptoCurrency $crypto): bool => $crypto->symbol === 'ETH');
```
-
-
#### Retrieve single elements
- `first`: Retrieves the first element from the Collection or returns a default value if the Collection is empty.
```php
- $collection->first(defaultValueIfNotFound: 'default');
+ $collection->first(defaultValueIfNotFound: 'fallback');
```
-- `getBy`: Retrieves an element by its index or returns a default value if the index is out of range.
+- `getBy`: Retrieves an element by its zero-based index or returns a default value if the index is out of bounds.
```php
- $collection->getBy(index: 0, defaultValueIfNotFound: 'default');
+ $collection->getBy(index: 0, defaultValueIfNotFound: 'fallback');
```
- `last`: Retrieves the last element from the Collection or returns a default value if the Collection is empty.
```php
- $collection->last(defaultValueIfNotFound: 'default');
+ $collection->last(defaultValueIfNotFound: 'fallback');
```
-#### Retrieve collection elements
+#### Retrieve collection segments
-- `slice`: Extracts a portion of the collection, starting at the specified index and retrieving the specified number of
- elements.
- If length is negative, it excludes many elements from the end of the collection.
- If length is not provided or set to -1, it returns all elements from the specified index to the end of the collection.
+- `slice`: Extracts a contiguous segment of the collection, starting at the specified offset.
+ If length is negative, it excludes that many elements from the end.
+ If length is not provided or set to -1, it returns all elements from the specified offset to the end.
```php
- $collection->slice(index: 1, length: 2);
+ $collection->slice(offset: 1, length: 2);
```
+
+
### Comparing
-These methods enable comparing collections to check for equality or to apply other comparison logic.
+These methods enable comparing collections to check for equality or to verify element membership.
#### Check if collection contains element
-- `contains`: Checks if the Collection contains a specific element.
+- `contains`: Checks if the Collection contains a specific element. Uses strict equality for scalars and loose equality
+ for objects.
```php
$collection->contains(element: 5);
@@ -252,7 +287,7 @@ These methods enable comparing collections to check for equality or to apply oth
#### Compare collections for equality
-- `equals`: Compares the current Collection with another collection to check if they are equal.
+- `equals`: Compares the current Collection with another collection for element-wise equality.
```php
$collectionA->equals(other: $collectionB);
@@ -265,12 +300,20 @@ These methods enable comparing collections to check for equality or to apply oth
These methods perform operations that return a single value based on the Collection's content, such as summing or
combining elements.
-- `reduce`: Combines all elements in the Collection into a single value using the provided aggregator function and an
- initial value.
- This method is helpful for accumulating results, like summing or concatenating values.
+- `reduce`: Combines all elements in the Collection into a single value using the provided accumulator function and an
+ initial value. This method is helpful for accumulating results, like summing or concatenating values.
```php
- $collection->reduce(aggregator: static fn(float $carry, float $amount): float => $carry + $amount, initial: 0.0)
+ $collection->reduce(
+ accumulator: static fn(float $carry, float $amount): float => $carry + $amount,
+ initial: 0.0
+ );
+ ```
+
+- `joinToString`: Joins all elements into a string with the given separator.
+
+ ```php
+ $collection->joinToString(separator: ', ');
```
@@ -282,18 +325,18 @@ These methods allow the Collection's elements to be transformed or converted int
#### Applying actions without modifying elements
- `each`: Executes actions on each element in the Collection without modification.
- The method is helpful for performing side effects, such as logging or adding elements to another collection.
+ The method is helpful for performing side effects, such as logging or accumulating values.
```php
- $collection->each(actions: static fn(Invoice $invoice): void => $collectionB->add(elements: new InvoiceSummary(amount: $invoice->amount, customer: $invoice->customer)));
+ $collection->each(actions: static fn(Amount $amount): void => $total += $amount->value);
```
#### Grouping elements
-- `groupBy`: Groups the elements in the Collection based on the provided grouping criterion.
+- `groupBy`: Groups the elements in the Collection based on the provided classifier.
```php
- $collection->groupBy(grouping: static fn(Amount $amount): string => $amount->currency->name);
+ $collection->groupBy(classifier: static fn(Amount $amount): string => $amount->currency->name);
```
#### Mapping elements
@@ -307,11 +350,7 @@ These methods allow the Collection's elements to be transformed or converted int
#### Flattening elements
-- `flatten`: Flattens a collection by removing any nested collections and returning a single collection with all
- elements in a single level.
-
- This method recursively flattens any iterable elements, combining them into one collection, regardless of their
- nesting depth.
+- `flatten`: Flattens nested iterables by exactly one level. Non-iterable elements are yielded as-is.
```php
$collection->flatten();
@@ -322,16 +361,16 @@ These methods allow the Collection's elements to be transformed or converted int
- `toArray`: Converts the Collection into an array.
```
- PreserveKeys::DISCARD: Converts while discarding the keys.
- PreserveKeys::PRESERVE: Converts while preserving the original keys.
+ KeyPreservation::DISCARD: Converts while discarding the keys.
+ KeyPreservation::PRESERVE: Converts while preserving the original keys.
```
- By default, `PreserveKeys::PRESERVE` is used.
+ By default, `KeyPreservation::PRESERVE` is used.
```php
use TinyBlocks\Mapper\KeyPreservation;
- $collection->toArray(preserveKeys: KeyPreservation::DISCARD);
+ $collection->toArray(keyPreservation: KeyPreservation::DISCARD);
```
#### Convert to JSON
@@ -339,16 +378,16 @@ These methods allow the Collection's elements to be transformed or converted int
- `toJson`: Converts the Collection into a JSON string.
```
- PreserveKeys::DISCARD: Converts while discarding the keys.
- PreserveKeys::PRESERVE: Converts while preserving the original keys.
+ KeyPreservation::DISCARD: Converts while discarding the keys.
+ KeyPreservation::PRESERVE: Converts while preserving the original keys.
```
- By default, `PreserveKeys::PRESERVE` is used.
+ By default, `KeyPreservation::PRESERVE` is used.
```php
use TinyBlocks\Mapper\KeyPreservation;
- $collection->toJson(preserveKeys: KeyPreservation::DISCARD);
+ $collection->toJson(keyPreservation: KeyPreservation::DISCARD);
```
@@ -376,6 +415,15 @@ chained operations.
However, this also means that some operations will consume the generator, and you cannot access the elements unless you
recreate the `Collection`.
+### 03. What is the difference between eager and lazy evaluation?
+
+- **Eager evaluation** (`createFrom` / `createFromEmpty`): Elements are materialized immediately into an array, enabling
+ constant-time access by index, count, and repeated iteration.
+
+- **Lazy evaluation** (`createLazyFrom` / `createLazyFromEmpty`): Elements are processed on-demand through generators,
+ consuming memory only as each element is yielded. Ideal for large datasets or pipelines where not all elements need to
+ be materialized.
+
## License
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index 90bbd03..c99c9a0 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -4,8 +4,9 @@ parameters:
level: 9
tmpDir: report/phpstan
ignoreErrors:
- - '#Unsafe usage of new static#'
- - '#does not specify its types#'
- - '#type specified in iterable type#'
- - '#Generator expects key type#'
+ - '#mixed given#'
+ - '#iterable type#'
+ - '#of new static#'
+ - '#generic interface#'
+ - '#destructuring on mixed#'
reportUnmatchedIgnoredErrors: false
diff --git a/src/Collectible.php b/src/Collectible.php
index 7258192..993800b 100644
--- a/src/Collectible.php
+++ b/src/Collectible.php
@@ -8,298 +8,243 @@
use Countable;
use IteratorAggregate;
use TinyBlocks\Mapper\KeyPreservation;
-use Traversable;
/**
- * Represents a collection that can be manipulated, iterated, and counted.
+ * Immutable, type-safe collection contract with a fluent API.
*
- * Complexity notes (Big O):
- * - Unless stated otherwise, complexities refer to consuming the collection **once**.
- * - `n`: number of elements produced when consuming the collection once.
- * - Callback cost is not included (assumed O(1) per callback invocation).
+ * Every mutating method returns a new instance, preserving immutability.
*
- * @template Key of int|string
- * @template Value of mixed
- * @template Element of mixed
- * @extends IteratorAggregate
+ * Two evaluation strategies are available:
+ *
+ * - createFrom / createFromEmpty — eager evaluation, materialized immediately.
+ * - createLazyFrom / createLazyFromEmpty — lazy evaluation via generators, on-demand.
*/
interface Collectible extends Countable, IteratorAggregate
{
/**
- * Creates a new Collectible instance from the given elements.
+ * Creates a collection populated with the given elements using eager evaluation.
*
- * Complexity: O(1) time and O(1) space to create the collection.
- * Consuming the collection is O(n) time and O(1) additional space.
+ * Elements are materialized immediately into an array, enabling
+ * constant-time access by index, count, and repeated iteration.
*
- * @param iterable $elements The elements to initialize the Collection with.
- * @return Collectible A new Collectible instance.
+ * @param iterable $elements The elements to populate the collection with.
+ * @return static A new collection containing the given elements.
*/
- public static function createFrom(iterable $elements): Collectible;
+ public static function createFrom(iterable $elements): static;
/**
- * Creates an empty Collectible instance.
- *
- * Complexity: O(1) time and O(1) space.
+ * Creates an empty collection using eager evaluation.
*
- * @return Collectible An empty Collectible instance.
+ * @return static An empty collection.
*/
- public static function createFromEmpty(): Collectible;
+ public static function createFromEmpty(): static;
/**
- * Adds one or more elements to the Collection.
+ * Creates a collection populated with the given elements using lazy evaluation.
*
- * Complexity (when consumed): O(n + k) time and O(1) additional space,
- * where `k` is the number of elements passed to this method.
+ * Elements are processed on-demand through generators, consuming
+ * memory only as each element is yielded.
*
- * @param Element ...$elements The elements to be added to the Collection.
- * @return Collectible The updated Collection.
+ * @param iterable $elements The elements to populate the collection with.
+ * @return static A new collection containing the given elements.
*/
- public function add(mixed ...$elements): Collectible;
+ public static function createLazyFrom(iterable $elements): static;
/**
- * Checks if the Collection contains a specific element.
- *
- * Complexity: best-case O(1), worst-case O(n) time (early termination), O(1) space.
+ * Creates an empty collection using lazy evaluation.
*
- * @param Element $element The element to check for.
- * @return bool True if the element is found, false otherwise.
+ * @return static An empty collection.
*/
- public function contains(mixed $element): bool;
+ public static function createLazyFromEmpty(): static;
/**
- * Returns the total number of elements in the Collection.
- *
- * Complexity: O(n) time and O(1) additional space.
+ * Returns a new collection with the specified elements appended.
*
- * @return int The number of elements in the Collection.
+ * @param mixed ...$elements The elements to append.
+ * @return static A new collection with the additional elements.
*/
- public function count(): int;
+ public function add(mixed ...$elements): static;
/**
- * Executes actions on each element in the Collection without modifying it.
- *
- * Complexity: O(n · a) time and O(1) additional space,
- * where `a` is the number of actions passed to this method.
+ * Merges the elements of another Collectible into the current Collection.
*
- * @param Closure(Element): void ...$actions The actions to perform on each element.
- * @return Collectible The original Collection for chaining.
+ * @param Collectible $other The collection to merge with.
+ * @return static A new collection containing elements from both collections.
*/
- public function each(Closure ...$actions): Collectible;
+ public function merge(Collectible $other): static;
/**
- * Compares the Collection with another Collection for equality.
+ * Determines whether the collection contains the specified element.
*
- * Complexity: best-case O(1), worst-case O(min(n, m)) time (early termination), O(1) space,
- * where `m` is the size of the other collection.
+ * Uses strict equality for scalars and loose equality for objects.
*
- * @param Collectible $other The Collection to compare with.
- * @return bool True if the collections are equal, false otherwise.
+ * @param mixed $element The element to search for.
+ * @return bool True if the element exists, false otherwise.
*/
- public function equals(Collectible $other): bool;
+ public function contains(mixed $element): bool;
/**
- * Filters elements in the Collection based on the provided predicates.
- * If no predicates are provided, all empty or falsy values (e.g., null, false, empty arrays) will be removed.
- *
- * Complexity (when consumed): O(n · p) time and O(1) additional space,
- * where `p` is the number of predicates.
+ * Returns the total number of elements.
*
- * @param Closure(Element): bool|null ...$predicates
- * @return Collectible The updated Collection.
+ * @return int The element count.
*/
- public function filter(?Closure ...$predicates): Collectible;
+ public function count(): int;
/**
- * Finds the first element that matches any of the provided predicates.
- *
- * Complexity: best-case O(1), worst-case O(n · q) time (early termination), O(1) space,
- * where `q` is the number of predicates.
+ * Finds the first element that satisfies any given predicate.
+ * Without predicates, returns null.
*
- * @param Closure(Element): bool ...$predicates The predicates to match (evaluated as a logical OR).
- * @return Element|null The first matching element, or null if none is found.
+ * @param Closure ...$predicates Conditions to test each element against.
+ * @return mixed The first matching element or null if no match is found.
*/
public function findBy(Closure ...$predicates): mixed;
/**
- * Retrieves the first element in the Collection or a default value if not found.
- *
- * Complexity: best-case O(1), worst-case O(n) time (early termination), O(1) space.
+ * Executes side effect actions on every element without modifying the collection.
*
- * @param Element|null $defaultValueIfNotFound The default value returns if no element is found.
- * @return Element|null The first element or the default value.
+ * @param Closure ...$actions Actions to perform on each element.
+ * @return static The same instance, enabling further chaining.
*/
- public function first(mixed $defaultValueIfNotFound = null): mixed;
+ public function each(Closure ...$actions): static;
/**
- * Flattens the collection by expanding iterable elements by one level (shallow flatten).
+ * Compares this collection with another for element-wise equality.
*
- * Complexity (when consumed): O(n + s) time and O(1) additional space, where `s` is the total number of elements
- * inside nested iterables that are expanded.
+ * Two collections are equal when they have the same size and every
+ * pair at the same position satisfies the equality comparison.
*
- * @return Collectible A new Collectible instance with elements flattened by one level.
+ * @param Collectible $other The collection to compare against.
+ * @return bool True if both collections are element-wise equal.
*/
- public function flatten(): Collectible;
+ public function equals(Collectible $other): bool;
/**
- * Retrieves an element by its index or a default value if not found.
+ * Returns a new collection with the specified element removed.
*
- * Complexity: O(n) time and O(1) additional space.
+ * All occurrences of the element are removed.
*
- * @param int $index The index of the element to retrieve.
- * @param Element|null $defaultValueIfNotFound The default value returns if no element is found.
- * @return Element|null The element at the specified index or the default value.
+ * @param mixed $element The element to remove.
+ * @return static A new collection without the specified element.
*/
- public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed;
+ public function remove(mixed $element): static;
/**
- * Returns an iterator for traversing the Collection.
- *
- * Complexity: O(1) time and O(1) space to obtain the iterator.
+ * Returns a new collection with all elements removed that satisfy the given predicate.
+ * When no predicate is provided (i.e., $predicate is null), all elements are removed.
*
- * @return Traversable An iterator for the Collection.
+ * @param Closure|null $predicate Condition to determine which elements to remove.
+ * @return static A new collection with the matching elements removed.
*/
- public function getIterator(): Traversable;
+ public function removeAll(?Closure $predicate = null): static;
/**
- * Groups the elements in the Collection based on the provided criteria.
+ * Retains only elements satisfying all given predicates.
*
- * Complexity (when consumed): O(n) time and O(n) additional space (materializes all groups).
+ * Without predicates, falsy values are removed.
*
- * @param Closure(Element): Key $grouping The function to define the group key for each element.
- * @return Collectible, Element> A Collection where each value is a list of elements,
- * grouped by the key returned by the closure.
+ * @param Closure|null ...$predicates Conditions each element must meet.
+ * @return static A new collection with only the matching elements.
*/
- public function groupBy(Closure $grouping): Collectible;
+ public function filter(?Closure ...$predicates): static;
/**
- * Determines if the Collection is empty.
- *
- * Complexity: best-case O(1), worst-case O(n) time (may need to advance until the first element is produced),
- * O(1) space.
+ * Returns the first element, or a default if the collection is empty.
*
- * @return bool True if the Collection is empty, false otherwise.
+ * @param mixed $defaultValueIfNotFound Value returned when the collection is empty.
+ * @return mixed The first element or the default.
*/
- public function isEmpty(): bool;
+ public function first(mixed $defaultValueIfNotFound = null): mixed;
/**
- * Joins the elements of the Collection into a string, separated by a given separator.
+ * Flattens nested iterables by exactly one level. Non-iterable elements are yielded as-is.
*
- * Complexity: O(n + L) time and O(L) space, where `L` is the length of the resulting string.
- *
- * @param string $separator The string used to separate the elements.
- * @return string The concatenated string of all elements in the Collection.
+ * @return static A new collection with elements flattened by one level.
*/
- public function joinToString(string $separator): string;
+ public function flatten(): static;
/**
- * Retrieves the last element in the Collection or a default value if not found.
- *
- * Complexity: O(n) time and O(1) space.
+ * Returns the element at the given zero-based index.
*
- * @param Element|null $defaultValueIfNotFound The default value returns if no element is found.
- * @return Element|null The last element or the default value.
+ * @param int $index The zero-based position.
+ * @param mixed $defaultValueIfNotFound Value returned when the index is out of bounds.
+ * @return mixed The element at the index or the default.
*/
- public function last(mixed $defaultValueIfNotFound = null): mixed;
+ public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed;
/**
- * Applies transformations to each element in the Collection and returns a new Collection with the transformed
- * elements.
+ * Groups elements by a key derived from each element.
*
- * Complexity (when consumed): O(n · t) time and O(1) additional space,
- * where `t` is the number of transformations.
+ * The classifier receives each element and must return the group key.
+ * The resulting collection contains key to element-list pairs.
*
- * @param Closure(Element): Element ...$transformations The transformations to apply.
- * @return Collectible A new Collection with the applied transformations.
+ * @param Closure $classifier Maps each element to its group key.
+ * @return static A new collection of grouped elements.
*/
- public function map(Closure ...$transformations): Collectible;
+ public function groupBy(Closure $classifier): static;
/**
- * Merges the elements of another Collectible into the current Collection.
+ * Determines whether the collection has no elements.
*
- * Unlike {@see add()}, which accepts individual elements via variadic parameters,
- * this method accepts an entire Collectible and concatenates its elements lazily
- * without materializing either collection.
- *
- * Complexity (when consumed): O(n + m) time and O(1) additional space,
- * where `m` is the number of elements in the other Collectible.
- *
- * @param Collectible $other The Collectible whose elements will be appended.
- * @return Collectible A new Collection containing elements from both collections.
+ * @return bool True if the collection is empty.
*/
- public function merge(Collectible $other): Collectible;
+ public function isEmpty(): bool;
/**
- * Removes a specific element from the Collection.
+ * Joins all elements into a string with the given separator.
*
- * Complexity (when consumed): O(n) time and O(1) additional space.
- *
- * @param Element $element The element to remove.
- * @return Collectible The updated Collection.
+ * @param string $separator The delimiter placed between each element.
+ * @return string The concatenated result.
*/
- public function remove(mixed $element): Collectible;
+ public function joinToString(string $separator): string;
/**
- * Removes elements from the Collection based on the provided filter.
- * If no filter is passed, all elements in the Collection will be removed.
+ * Returns the last element, or a default if the collection is empty.
*
- * Complexity (when consumed): O(n) time and O(1) additional space.
- *
- * @param Closure(Element): bool|null $filter The filter to determine which elements to remove.
- * @return Collectible The updated Collection.
+ * @param mixed $defaultValueIfNotFound Value returned when the collection is empty.
+ * @return mixed The last element or the default.
*/
- public function removeAll(?Closure $filter = null): Collectible;
+ public function last(mixed $defaultValueIfNotFound = null): mixed;
/**
- * Reduces the elements in the Collection to a single value by applying an aggregator function.
+ * Applies one or more transformation functions to each element.
*
- * Complexity: O(n) time and O(1) additional space.
+ * Transformations are applied in order. Each receives the current value and key.
*
- * @param Closure(mixed, Element): mixed $aggregator The function that aggregates the elements.
- * It receives the current accumulated value and the current element.
- * @param mixed $initial The initial value to start the aggregation.
- * @return mixed The final aggregated result.
+ * @param Closure ...$transformations Functions applied to each element.
+ * @return static A new collection with the transformed elements.
*/
- public function reduce(Closure $aggregator, mixed $initial): mixed;
+ public function map(Closure ...$transformations): static;
/**
- * Sorts the Collection based on the provided order and predicate.
- *
- * The order should be provided from the `Order` enum:
- * - {@see Order::ASCENDING_KEY}: Sorts in ascending order by key.
- * - {@see Order::DESCENDING_KEY}: Sorts in descending order by key.
- * - {@see Order::ASCENDING_VALUE}: Sorts in ascending order by value.
- * - {@see Order::DESCENDING_VALUE}: Sorts in descending order by value.
+ * Reduces the collection to a single accumulated value.
*
- * By default, `Order::ASCENDING_KEY` is used.
+ * The accumulator receives the carry and the current element.
*
- * Complexity (when consumed): O(n log n) time and O(n) additional space (materializes elements to sort).
- *
- * @param Order $order The order in which to sort the Collection.
- * @param Closure(Element, Element): int|null $predicate The predicate to use for sorting.
- * @return Collectible The updated Collection.
+ * @param Closure $accumulator Combines the carry with each element.
+ * @param mixed $initial The starting value for the accumulation.
+ * @return mixed The final accumulated result.
*/
- public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $predicate = null): Collectible;
+ public function reduce(Closure $accumulator, mixed $initial): mixed;
/**
- * Returns a subset of the Collection starting at the specified index and containing the specified number of
- * elements.
- *
- * If the `length` is negative, it will exclude that many elements from the end of the Collection.
- * If the `length` is not provided or set to `-1`, it returns all elements starting from the index until the end.
+ * Returns a new collection sorted by the given order and optional comparator.
*
- * @param int $index The zero-based index at which to start the slice.
- * @param int $length The number of elements to include in the slice. If negative, remove that many from the end.
- * Default is `-1`, meaning all elements from the index onward will be included.
+ * Without a comparator, the spaceship operator is used.
*
- * Complexity (when consumed):
- * - If `length === 0`: O(1) time and O(1) additional space.
- * - If `length === -1`: O(n) time and O(1) additional space.
- * - If `length >= 0`: O(min(n, index + length)) time and O(1) additional space (may stop early).
- * - If `length < -1`: O(n) time and O(|length|) additional space (uses a buffer).
+ * @param Order $order The sorting direction.
+ * @param Closure|null $comparator Custom comparison function.
+ * @return static A new sorted collection.
+ */
+ public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $comparator = null): static;
+
+ /**
+ * Extracts a contiguous segment of the collection.
*
- * @return Collectible A new Collection containing the sliced elements.
+ * @param int $offset Zero-based starting position.
+ * @param int $length Number of elements to include. Use -1 for "until the end".
+ * @return static A new collection with the extracted segment.
*/
- public function slice(int $index, int $length = -1): Collectible;
+ public function slice(int $offset, int $length = -1): static;
/**
* Converts the Collection to an array.
@@ -310,10 +255,8 @@ public function slice(int $index, int $length = -1): Collectible;
*
* By default, `KeyPreservation::PRESERVE` is used.
*
- * Complexity: O(n) time and O(n) space.
- *
* @param KeyPreservation $keyPreservation The option to preserve or discard array keys.
- * @return array The resulting array.
+ * @return array The resulting array.
*/
public function toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): array;
@@ -326,8 +269,6 @@ public function toArray(KeyPreservation $keyPreservation = KeyPreservation::PRES
*
* By default, `KeyPreservation::PRESERVE` is used.
*
- * Complexity: O(n + L) time and O(n + L) space, where `L` is the length of the resulting JSON.
- *
* @param KeyPreservation $keyPreservation The option to preserve or discard array keys.
* @return string The resulting JSON string.
*/
diff --git a/src/Collection.php b/src/Collection.php
index 6feb921..0533484 100644
--- a/src/Collection.php
+++ b/src/Collection.php
@@ -5,176 +5,181 @@
namespace TinyBlocks\Collection;
use Closure;
-use TinyBlocks\Collection\Internal\Iterators\LazyIterator;
-use TinyBlocks\Collection\Internal\Operations\Aggregate\Reduce;
-use TinyBlocks\Collection\Internal\Operations\Compare\Contains;
-use TinyBlocks\Collection\Internal\Operations\Compare\Equals;
-use TinyBlocks\Collection\Internal\Operations\Filter\Filter;
-use TinyBlocks\Collection\Internal\Operations\Order\Sort;
-use TinyBlocks\Collection\Internal\Operations\Retrieve\Find;
-use TinyBlocks\Collection\Internal\Operations\Retrieve\First;
-use TinyBlocks\Collection\Internal\Operations\Retrieve\Get;
-use TinyBlocks\Collection\Internal\Operations\Retrieve\Last;
-use TinyBlocks\Collection\Internal\Operations\Retrieve\Slice;
-use TinyBlocks\Collection\Internal\Operations\Transform\Each;
-use TinyBlocks\Collection\Internal\Operations\Transform\Flatten;
-use TinyBlocks\Collection\Internal\Operations\Transform\GroupBy;
-use TinyBlocks\Collection\Internal\Operations\Transform\JoinToString;
-use TinyBlocks\Collection\Internal\Operations\Transform\Map;
-use TinyBlocks\Collection\Internal\Operations\Write\Add;
-use TinyBlocks\Collection\Internal\Operations\Write\Create;
-use TinyBlocks\Collection\Internal\Operations\Write\Merge;
-use TinyBlocks\Collection\Internal\Operations\Write\Remove;
-use TinyBlocks\Collection\Internal\Operations\Write\RemoveAll;
+use TinyBlocks\Collection\Internal\EagerPipeline;
+use TinyBlocks\Collection\Internal\LazyPipeline;
+use TinyBlocks\Collection\Internal\Operations\Operation;
+use TinyBlocks\Collection\Internal\Operations\Resolving\Each;
+use TinyBlocks\Collection\Internal\Operations\Resolving\Equality;
+use TinyBlocks\Collection\Internal\Operations\Resolving\Find;
+use TinyBlocks\Collection\Internal\Operations\Resolving\First;
+use TinyBlocks\Collection\Internal\Operations\Resolving\Join;
+use TinyBlocks\Collection\Internal\Operations\Resolving\Last;
+use TinyBlocks\Collection\Internal\Operations\Resolving\Reduce;
+use TinyBlocks\Collection\Internal\Operations\Transforming\Add;
+use TinyBlocks\Collection\Internal\Operations\Transforming\Filter;
+use TinyBlocks\Collection\Internal\Operations\Transforming\FlatMap;
+use TinyBlocks\Collection\Internal\Operations\Transforming\GroupInto;
+use TinyBlocks\Collection\Internal\Operations\Transforming\Map;
+use TinyBlocks\Collection\Internal\Operations\Transforming\Merge;
+use TinyBlocks\Collection\Internal\Operations\Transforming\Rearrange;
+use TinyBlocks\Collection\Internal\Operations\Transforming\Remove;
+use TinyBlocks\Collection\Internal\Operations\Transforming\RemoveAll;
+use TinyBlocks\Collection\Internal\Operations\Transforming\Segment;
+use TinyBlocks\Collection\Internal\Pipeline;
use TinyBlocks\Mapper\IterableMappability;
use TinyBlocks\Mapper\IterableMapper;
use Traversable;
/**
- * Represents a collection that provides a set of utility methods for operations like adding,
- * filtering, mapping, and transforming elements. Internally uses iterators to apply operations
- * lazily and efficiently.
+ * Extensible, type-safe collection with a fluent API.
+ *
+ * Designed as the primary extension point — domain collections should
+ * extend this class to inherit all collection behavior:
+ *
+ * final class Orders extends Collection { }
*/
class Collection implements Collectible, IterableMapper
{
use IterableMappability;
- private LazyIterator $iterator;
-
- private function __construct(LazyIterator $iterator)
+ private function __construct(private readonly Pipeline $pipeline)
{
- $this->iterator = $iterator;
}
public static function createFrom(iterable $elements): static
{
- return new static(iterator: LazyIterator::from(elements: $elements, operation: Create::fromEmpty()));
+ return new static(pipeline: EagerPipeline::from(source: $elements));
}
public static function createFromEmpty(): static
{
- return self::createFrom(elements: []);
+ return static::createFrom(elements: []);
+ }
+
+ public static function createLazyFrom(iterable $elements): static
+ {
+ return new static(pipeline: LazyPipeline::from(source: $elements));
+ }
+
+ public static function createLazyFromEmpty(): static
+ {
+ return static::createLazyFrom(elements: []);
+ }
+
+ public function getIterator(): Traversable
+ {
+ yield from $this->pipeline->process();
}
public function add(mixed ...$elements): static
{
- return new static(iterator: $this->iterator->add(operation: Add::from(newElements: $elements)));
+ return $this->pipeTo(operation: Add::these(newElements: $elements));
+ }
+
+ public function merge(Collectible $other): static
+ {
+ return $this->pipeTo(operation: Merge::with(other: $other));
}
public function contains(mixed $element): bool
{
- return Contains::from(elements: $this->iterator)->exists(element: $element);
+ return Equality::exists(elements: $this, element: $element);
}
public function count(): int
{
- return iterator_count($this->iterator);
+ return $this->pipeline->count();
+ }
+
+ public function findBy(Closure ...$predicates): mixed
+ {
+ return Find::firstMatch(elements: $this, predicates: $predicates);
}
public function each(Closure ...$actions): static
{
- Each::from(...$actions)->execute(elements: $this->iterator);
+ Each::execute(elements: $this, actions: $actions);
+
return $this;
}
public function equals(Collectible $other): bool
{
- return Equals::from(elements: $this->iterator)->compareAll(other: $other);
+ return Equality::compareAll(elements: $this, other: $other);
}
- public function filter(?Closure ...$predicates): static
+ public function remove(mixed $element): static
{
- return new static(iterator: $this->iterator->add(operation: Filter::from(...$predicates)));
+ return $this->pipeTo(operation: Remove::element(element: $element));
}
- public function findBy(Closure ...$predicates): mixed
+ public function removeAll(?Closure $predicate = null): static
{
- return Find::from(elements: $this->iterator)->firstMatchingElement(...$predicates);
+ return $this->pipeTo(operation: RemoveAll::matching(predicate: $predicate));
}
- public function first(mixed $defaultValueIfNotFound = null): mixed
+ public function filter(?Closure ...$predicates): static
{
- return First::from(elements: $this->iterator)->element(defaultValueIfNotFound: $defaultValueIfNotFound);
+ return $this->pipeTo(operation: Filter::matching(...$predicates));
}
- public function flatten(): static
+ public function first(mixed $defaultValueIfNotFound = null): mixed
{
- return new static(iterator: $this->iterator->add(operation: Flatten::instance()));
+ return First::from(elements: $this, defaultValueIfNotFound: $defaultValueIfNotFound);
}
- public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed
+ public function flatten(): static
{
- return Get::from(elements: $this->iterator)->elementAtIndex(
- index: $index,
- defaultValueIfNotFound: $defaultValueIfNotFound
- );
+ return $this->pipeTo(operation: FlatMap::oneLevel());
}
- public function getIterator(): Traversable
+ public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed
{
- yield from $this->iterator->getIterator();
+ return $this->pipeline->getBy(index: $index, defaultValueIfNotFound: $defaultValueIfNotFound);
}
- public function groupBy(Closure $grouping): Collectible
+ public function groupBy(Closure $classifier): static
{
- return new static(iterator: $this->iterator->add(operation: GroupBy::from(grouping: $grouping)));
+ return $this->pipeTo(operation: GroupInto::by(classifier: $classifier));
}
public function isEmpty(): bool
{
- return !$this->iterator->getIterator()->valid();
+ return First::isAbsent(elements: $this);
}
public function joinToString(string $separator): string
{
- return JoinToString::from(elements: $this->iterator)->joinTo(separator: $separator);
+ return Join::elements(elements: $this, separator: $separator);
}
public function last(mixed $defaultValueIfNotFound = null): mixed
{
- return Last::from(elements: $this->iterator)->element(defaultValueIfNotFound: $defaultValueIfNotFound);
+ return Last::from(elements: $this, defaultValueIfNotFound: $defaultValueIfNotFound);
}
public function map(Closure ...$transformations): static
{
- return new static(iterator: $this->iterator->add(operation: Map::from(...$transformations)));
- }
-
- public function merge(Collectible $other): static
- {
- return new static(iterator: $this->iterator->add(operation: Merge::from(otherElements: $other)));
- }
-
- public function remove(mixed $element): static
- {
- return new static(iterator: $this->iterator->add(operation: Remove::from(element: $element)));
+ return $this->pipeTo(operation: Map::using(...$transformations));
}
- public function removeAll(?Closure $filter = null): static
+ public function reduce(Closure $accumulator, mixed $initial): mixed
{
- return new static(iterator: $this->iterator->add(operation: RemoveAll::from(filter: $filter)));
+ return Reduce::from(elements: $this, accumulator: $accumulator, initial: $initial);
}
- public function reduce(Closure $aggregator, mixed $initial): mixed
+ public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $comparator = null): static
{
- return Reduce::from(elements: $this->iterator)->execute(aggregator: $aggregator, initial: $initial);
+ return $this->pipeTo(operation: Rearrange::by(order: $order, comparator: $comparator));
}
- public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $predicate = null): static
+ public function slice(int $offset, int $length = -1): static
{
- return new static(
- iterator: $this->iterator->add(
- operation: Sort::from(order: $order, predicate: $predicate)
- )
- );
+ return $this->pipeTo(operation: Segment::from(offset: $offset, length: $length));
}
- public function slice(int $index, int $length = -1): static
+ private function pipeTo(Operation $operation): static
{
- return new static(
- iterator: $this->iterator->add(
- operation: Slice::from(index: $index, length: $length)
- )
- );
+ return new static(pipeline: $this->pipeline->pipe(operation: $operation));
}
}
diff --git a/src/Internal/EagerPipeline.php b/src/Internal/EagerPipeline.php
new file mode 100644
index 0000000..7dff0bc
--- /dev/null
+++ b/src/Internal/EagerPipeline.php
@@ -0,0 +1,53 @@
+apply(elements: $this->elements));
+
+ return new EagerPipeline(elements: $elements);
+ }
+
+ public function count(): int
+ {
+ return count($this->elements);
+ }
+
+ public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed
+ {
+ return array_key_exists($index, $this->elements)
+ ? $this->elements[$index]
+ : $defaultValueIfNotFound;
+ }
+
+ public function process(): Generator
+ {
+ yield from $this->elements;
+ }
+}
diff --git a/src/Internal/Iterators/LazyIterator.php b/src/Internal/Iterators/LazyIterator.php
deleted file mode 100644
index bbfb922..0000000
--- a/src/Internal/Iterators/LazyIterator.php
+++ /dev/null
@@ -1,68 +0,0 @@
-
- */
-final class LazyIterator implements IteratorAggregate
-{
- /**
- * @var LazyOperation[]
- */
- private array $operations;
-
- /**
- * @param iterable $elements
- * @param LazyOperation $operation
- */
- private function __construct(private readonly iterable $elements, LazyOperation $operation)
- {
- $this->operations[] = $operation;
- }
-
- /**
- * @param iterable $elements
- * @param LazyOperation $operation
- * @return LazyIterator
- */
- public static function from(iterable $elements, LazyOperation $operation): LazyIterator
- {
- return new LazyIterator(elements: $elements, operation: $operation);
- }
-
- /**
- * @param LazyOperation $operation
- * @return LazyIterator
- */
- public function add(LazyOperation $operation): LazyIterator
- {
- $this->operations[] = $operation;
- return $this;
- }
-
- /**
- * @return Generator
- */
- public function getIterator(): Generator
- {
- $currentElements = $this->elements;
-
- foreach ($this->operations as $operation) {
- $currentElements = $operation->apply(elements: $currentElements);
- }
-
- yield from $currentElements;
- }
-}
diff --git a/src/Internal/Iterators/LazyIteratorAggregate.php b/src/Internal/Iterators/LazyIteratorAggregate.php
deleted file mode 100644
index 2eeaa90..0000000
--- a/src/Internal/Iterators/LazyIteratorAggregate.php
+++ /dev/null
@@ -1,43 +0,0 @@
-
- */
-final readonly class LazyIteratorAggregate implements IteratorAggregate
-{
- /**
- * @param iterable $elements
- */
- private function __construct(private iterable $elements)
- {
- }
-
- /**
- * @param iterable $elements
- * @return LazyIteratorAggregate
- */
- public static function from(iterable $elements): LazyIteratorAggregate
- {
- return new LazyIteratorAggregate(elements: $elements);
- }
-
- /**
- * @return Generator
- */
- public function getIterator(): Generator
- {
- yield from $this->elements;
- }
-}
diff --git a/src/Internal/LazyPipeline.php b/src/Internal/LazyPipeline.php
new file mode 100644
index 0000000..a8cd6dc
--- /dev/null
+++ b/src/Internal/LazyPipeline.php
@@ -0,0 +1,68 @@
+stages = $stages;
+ }
+
+ public static function from(iterable $source): LazyPipeline
+ {
+ return new LazyPipeline(source: $source);
+ }
+
+ public function pipe(Operation $operation): Pipeline
+ {
+ $stages = $this->stages;
+ $stages[] = $operation;
+
+ return new LazyPipeline(source: $this->source, stages: $stages);
+ }
+
+ public function count(): int
+ {
+ return iterator_count($this->process());
+ }
+
+ public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed
+ {
+ foreach ($this->process() as $currentIndex => $value) {
+ if ($currentIndex === $index) {
+ return $value;
+ }
+ }
+
+ return $defaultValueIfNotFound;
+ }
+
+ public function process(): Generator
+ {
+ $elements = $this->source;
+
+ foreach ($this->stages as $stage) {
+ $elements = $stage->apply(elements: $elements);
+ }
+
+ yield from $elements;
+ }
+}
diff --git a/src/Internal/Operations/Aggregate/Reduce.php b/src/Internal/Operations/Aggregate/Reduce.php
deleted file mode 100644
index 5dd4961..0000000
--- a/src/Internal/Operations/Aggregate/Reduce.php
+++ /dev/null
@@ -1,31 +0,0 @@
-elements as $element) {
- $carry = $aggregator($carry, $element);
- }
-
- return $carry;
- }
-}
diff --git a/src/Internal/Operations/Compare/Contains.php b/src/Internal/Operations/Compare/Contains.php
deleted file mode 100644
index 9327a8c..0000000
--- a/src/Internal/Operations/Compare/Contains.php
+++ /dev/null
@@ -1,32 +0,0 @@
-elements as $current) {
- if ($equals->compareWith(element: $current, otherElement: $element)) {
- return true;
- }
- }
-
- return false;
- }
-}
diff --git a/src/Internal/Operations/Compare/Equals.php b/src/Internal/Operations/Compare/Equals.php
deleted file mode 100644
index 60b4976..0000000
--- a/src/Internal/Operations/Compare/Equals.php
+++ /dev/null
@@ -1,54 +0,0 @@
-getIterator();
- $targetIterator = LazyIteratorAggregate::from(elements: $this->elements)->getIterator();
-
- while ($currentIterator->valid() || $targetIterator->valid()) {
- if (!$currentIterator->valid() || !$targetIterator->valid()) {
- return false;
- }
-
- if (!$this->compareWith(element: $currentIterator->current(), otherElement: $targetIterator->current())) {
- return false;
- }
-
- $currentIterator->next();
- $targetIterator->next();
- }
-
- return true;
- }
-
- public function compareWith(mixed $element, mixed $otherElement): bool
- {
- return is_object($element) && is_object($otherElement)
- ? $element == $otherElement
- : $element === $otherElement;
- }
-}
diff --git a/src/Internal/Operations/Filter/Filter.php b/src/Internal/Operations/Filter/Filter.php
deleted file mode 100644
index 9dd4b8e..0000000
--- a/src/Internal/Operations/Filter/Filter.php
+++ /dev/null
@@ -1,50 +0,0 @@
-predicates = array_filter($predicates);
-
- $buildCompositePredicate = static fn(array $predicates): Closure => static fn(
- mixed $value,
- mixed $key
- ): bool => array_all(
- $predicates,
- static fn(Closure $predicate): bool => $predicate($value, $key)
- );
-
- $this->compiledPredicate = match (count($this->predicates)) {
- 0 => static fn(mixed $value, mixed $key): bool => (bool)$value,
- default => $buildCompositePredicate($this->predicates)
- };
- }
-
- public static function from(?Closure ...$predicates): Filter
- {
- return new Filter(...$predicates);
- }
-
- public function apply(iterable $elements): Generator
- {
- $predicate = $this->compiledPredicate;
-
- foreach ($elements as $key => $value) {
- if ($predicate($value, $key)) {
- yield $key => $value;
- }
- }
- }
-}
diff --git a/src/Internal/Operations/ImmediateOperation.php b/src/Internal/Operations/ImmediateOperation.php
deleted file mode 100644
index 0b9891e..0000000
--- a/src/Internal/Operations/ImmediateOperation.php
+++ /dev/null
@@ -1,12 +0,0 @@
- $elements The collection of elements to apply the operation on.
- * @return Generator A generator that yields the results of applying the operation.
- */
- public function apply(iterable $elements): Generator;
-}
diff --git a/src/Internal/Operations/Operation.php b/src/Internal/Operations/Operation.php
index 13b9752..3e6f0ba 100644
--- a/src/Internal/Operations/Operation.php
+++ b/src/Internal/Operations/Operation.php
@@ -4,9 +4,34 @@
namespace TinyBlocks\Collection\Internal\Operations;
+use Generator;
+
/**
- * Defines operations to the collection.
+ * Represents a single processing stage in a collection pipeline.
+ *
+ * Each operation encapsulates a discrete transformation that receives
+ * a stream of elements and produces a new stream. Operations are
+ * designed to be composed sequentially, forming a pipeline where
+ * the output of one stage feeds into the next.
+ *
+ * Implementations must be stateless relative to the input stream,
+ * meaning they should not retain references to previously seen elements
+ * unless the nature of the operation requires it (e.g., sorting).
+ *
+ * @template TKey of int|string
+ * @template TValue
*/
interface Operation
{
+ /**
+ * Processes the given elements and yields the resulting stream.
+ *
+ * The operation consumes elements on demand from the input iterable
+ * and produces transformed elements through a generator, preserving
+ * lazy evaluation semantics across the pipeline.
+ *
+ * @param iterable $elements The input stream of elements to process.
+ * @return Generator A generator yielding the processed elements.
+ */
+ public function apply(iterable $elements): Generator;
}
diff --git a/src/Internal/Operations/Order/Sort.php b/src/Internal/Operations/Order/Sort.php
deleted file mode 100644
index d310dc9..0000000
--- a/src/Internal/Operations/Order/Sort.php
+++ /dev/null
@@ -1,51 +0,0 @@
- $value) {
- $temporaryElements[$key] = $value;
- }
- }
-
- $predicate = is_null($this->predicate)
- ? static fn(mixed $first, mixed $second): int => $first <=> $second
- : $this->predicate;
-
- $ascendingPredicate = static fn(mixed $first, mixed $second): int => $predicate($first, $second);
- $descendingPredicate = static fn(mixed $first, mixed $second): int => $predicate($second, $first);
-
- match ($this->order) {
- Order::ASCENDING_KEY => ksort($temporaryElements),
- Order::DESCENDING_KEY => krsort($temporaryElements),
- Order::ASCENDING_VALUE => uasort($temporaryElements, $ascendingPredicate),
- Order::DESCENDING_VALUE => uasort($temporaryElements, $descendingPredicate)
- };
-
- foreach ($temporaryElements as $key => $value) {
- yield $key => $value;
- }
- }
-}
diff --git a/src/Internal/Operations/Resolving/Each.php b/src/Internal/Operations/Resolving/Each.php
new file mode 100644
index 0000000..b364f24
--- /dev/null
+++ b/src/Internal/Operations/Resolving/Each.php
@@ -0,0 +1,17 @@
+ $value) {
+ foreach ($actions as $action) {
+ $action($value, $key);
+ }
+ }
+ }
+}
diff --git a/src/Internal/Operations/Resolving/Equality.php b/src/Internal/Operations/Resolving/Equality.php
new file mode 100644
index 0000000..30c68e0
--- /dev/null
+++ b/src/Internal/Operations/Resolving/Equality.php
@@ -0,0 +1,55 @@
+valid() && $iteratorB->valid()) {
+ if (!Equality::areSame(element: $iteratorA->current(), other: $iteratorB->current())) {
+ return false;
+ }
+
+ $iteratorA->next();
+ $iteratorB->next();
+ }
+
+ return !$iteratorA->valid() && !$iteratorB->valid();
+ }
+
+ private static function toGenerator(iterable $iterable): Generator
+ {
+ yield from $iterable;
+ }
+}
diff --git a/src/Internal/Operations/Resolving/Find.php b/src/Internal/Operations/Resolving/Find.php
new file mode 100644
index 0000000..118fbde
--- /dev/null
+++ b/src/Internal/Operations/Resolving/Find.php
@@ -0,0 +1,21 @@
+ $predicate($element))) {
+ return $element;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/Internal/Operations/Resolving/First.php b/src/Internal/Operations/Resolving/First.php
new file mode 100644
index 0000000..94fc0ee
--- /dev/null
+++ b/src/Internal/Operations/Resolving/First.php
@@ -0,0 +1,26 @@
+elements as $element) {
- if (array_any($predicates, static fn(Closure $predicate): bool => $predicate($element))) {
- return $element;
- }
- }
-
- return null;
- }
-}
diff --git a/src/Internal/Operations/Retrieve/First.php b/src/Internal/Operations/Retrieve/First.php
deleted file mode 100644
index 2592c12..0000000
--- a/src/Internal/Operations/Retrieve/First.php
+++ /dev/null
@@ -1,28 +0,0 @@
-elements as $element) {
- return $element;
- }
-
- return $defaultValueIfNotFound;
- }
-}
diff --git a/src/Internal/Operations/Retrieve/Get.php b/src/Internal/Operations/Retrieve/Get.php
deleted file mode 100644
index 49218f3..0000000
--- a/src/Internal/Operations/Retrieve/Get.php
+++ /dev/null
@@ -1,30 +0,0 @@
-elements as $currentIndex => $value) {
- if ($currentIndex === $index) {
- return $value;
- }
- }
-
- return $defaultValueIfNotFound;
- }
-}
diff --git a/src/Internal/Operations/Retrieve/Last.php b/src/Internal/Operations/Retrieve/Last.php
deleted file mode 100644
index 764a4b8..0000000
--- a/src/Internal/Operations/Retrieve/Last.php
+++ /dev/null
@@ -1,30 +0,0 @@
-elements as $element) {
- $lastElement = $element;
- }
-
- return $lastElement;
- }
-}
diff --git a/src/Internal/Operations/Retrieve/Slice.php b/src/Internal/Operations/Retrieve/Slice.php
deleted file mode 100644
index 2403ad1..0000000
--- a/src/Internal/Operations/Retrieve/Slice.php
+++ /dev/null
@@ -1,75 +0,0 @@
-length === 0) {
- return;
- }
-
- if ($this->length < -1) {
- yield from $this->applyWithBufferedSlice(elements: $elements);
- return;
- }
-
- $currentIndex = 0;
- $yieldedCount = 0;
-
- foreach ($elements as $key => $value) {
- if ($currentIndex++ < $this->index) {
- continue;
- }
-
- yield $key => $value;
- $yieldedCount++;
-
- if ($this->length !== -1 && $yieldedCount >= $this->length) {
- return;
- }
- }
- }
-
- private function applyWithBufferedSlice(iterable $elements): Generator
- {
- $buffer = new SplQueue();
- $skipFromEnd = abs($this->length);
- $currentIndex = 0;
-
- foreach ($elements as $key => $value) {
- if ($currentIndex++ < $this->index) {
- continue;
- }
-
- $buffer->enqueue([$key, $value]);
-
- if ($buffer->count() <= $skipFromEnd) {
- continue;
- }
-
- $dequeued = $buffer->dequeue();
-
- if (is_array($dequeued)) {
- [$yieldKey, $yieldValue] = $dequeued;
- yield $yieldKey => $yieldValue;
- }
- }
- }
-}
diff --git a/src/Internal/Operations/Transform/Each.php b/src/Internal/Operations/Transform/Each.php
deleted file mode 100644
index 25d9bff..0000000
--- a/src/Internal/Operations/Transform/Each.php
+++ /dev/null
@@ -1,32 +0,0 @@
-actions = $actions;
- }
-
- public static function from(Closure ...$actions): Each
- {
- return new Each(...$actions);
- }
-
- public function execute(iterable $elements): void
- {
- foreach ($elements as $key => $value) {
- foreach ($this->actions as $action) {
- $action($value, $key);
- }
- }
- }
-}
diff --git a/src/Internal/Operations/Transform/Flatten.php b/src/Internal/Operations/Transform/Flatten.php
deleted file mode 100644
index a270129..0000000
--- a/src/Internal/Operations/Transform/Flatten.php
+++ /dev/null
@@ -1,31 +0,0 @@
-grouping)($element);
- $groupedElements[$key][] = $element;
- }
-
- foreach ($groupedElements as $key => $group) {
- yield $key => $group;
- }
- }
-}
diff --git a/src/Internal/Operations/Transform/JoinToString.php b/src/Internal/Operations/Transform/JoinToString.php
deleted file mode 100644
index 441aeff..0000000
--- a/src/Internal/Operations/Transform/JoinToString.php
+++ /dev/null
@@ -1,37 +0,0 @@
-elements as $element) {
- if ($first) {
- $result = $element;
- $first = false;
- continue;
- }
-
- $result .= sprintf('%s%s', $separator, $element);
- }
-
- return $result;
- }
-}
diff --git a/src/Internal/Operations/Transforming/Add.php b/src/Internal/Operations/Transforming/Add.php
new file mode 100644
index 0000000..79930bd
--- /dev/null
+++ b/src/Internal/Operations/Transforming/Add.php
@@ -0,0 +1,31 @@
+newElements as $value) {
+ yield $value;
+ }
+ }
+}
diff --git a/src/Internal/Operations/Transforming/Filter.php b/src/Internal/Operations/Transforming/Filter.php
new file mode 100644
index 0000000..debb77b
--- /dev/null
+++ b/src/Internal/Operations/Transforming/Filter.php
@@ -0,0 +1,43 @@
+compiledPredicate = match (count($filtered)) {
+ 0 => static fn(mixed $value, mixed $key): bool => (bool)$value,
+ default => static fn(mixed $value, mixed $key): bool => array_all(
+ array: $filtered,
+ callback: static fn(Closure $predicate): bool => $predicate($value, $key)
+ ),
+ };
+ }
+
+ public static function matching(?Closure ...$predicates): Filter
+ {
+ return new Filter(...$predicates);
+ }
+
+ public function apply(iterable $elements): Generator
+ {
+ $predicate = $this->compiledPredicate;
+
+ foreach ($elements as $key => $value) {
+ if ($predicate($value, $key)) {
+ yield $key => $value;
+ }
+ }
+ }
+}
diff --git a/src/Internal/Operations/Transforming/FlatMap.php b/src/Internal/Operations/Transforming/FlatMap.php
new file mode 100644
index 0000000..41b57b9
--- /dev/null
+++ b/src/Internal/Operations/Transforming/FlatMap.php
@@ -0,0 +1,31 @@
+classifier)($element);
+ $groups[$key][] = $element;
+ }
+
+ yield from $groups;
+ }
+}
diff --git a/src/Internal/Operations/Transform/Map.php b/src/Internal/Operations/Transforming/Map.php
similarity index 70%
rename from src/Internal/Operations/Transform/Map.php
rename to src/Internal/Operations/Transforming/Map.php
index 16afe45..eab652c 100644
--- a/src/Internal/Operations/Transform/Map.php
+++ b/src/Internal/Operations/Transforming/Map.php
@@ -2,13 +2,13 @@
declare(strict_types=1);
-namespace TinyBlocks\Collection\Internal\Operations\Transform;
+namespace TinyBlocks\Collection\Internal\Operations\Transforming;
use Closure;
use Generator;
-use TinyBlocks\Collection\Internal\Operations\LazyOperation;
+use TinyBlocks\Collection\Internal\Operations\Operation;
-final readonly class Map implements LazyOperation
+final readonly class Map implements Operation
{
private array $transformations;
@@ -17,7 +17,7 @@ private function __construct(Closure ...$transformations)
$this->transformations = $transformations;
}
- public static function from(Closure ...$transformations): Map
+ public static function using(Closure ...$transformations): Map
{
return new Map(...$transformations);
}
diff --git a/src/Internal/Operations/Transforming/Merge.php b/src/Internal/Operations/Transforming/Merge.php
new file mode 100644
index 0000000..4250f79
--- /dev/null
+++ b/src/Internal/Operations/Transforming/Merge.php
@@ -0,0 +1,31 @@
+other as $value) {
+ yield $value;
+ }
+ }
+}
diff --git a/src/Internal/Operations/Transforming/Rearrange.php b/src/Internal/Operations/Transforming/Rearrange.php
new file mode 100644
index 0000000..e36bb30
--- /dev/null
+++ b/src/Internal/Operations/Transforming/Rearrange.php
@@ -0,0 +1,42 @@
+comparator
+ ?? static fn(mixed $first, mixed $second): int => $first <=> $second;
+
+ match ($this->order) {
+ Order::ASCENDING_KEY => ksort($materialized),
+ Order::DESCENDING_KEY => krsort($materialized),
+ Order::ASCENDING_VALUE => uasort($materialized, $comparator),
+ Order::DESCENDING_VALUE => uasort(
+ $materialized,
+ static fn(mixed $first, mixed $second): int => $comparator($second, $first)
+ ),
+ };
+
+ yield from $materialized;
+ }
+}
diff --git a/src/Internal/Operations/Transforming/Remove.php b/src/Internal/Operations/Transforming/Remove.php
new file mode 100644
index 0000000..f225a2c
--- /dev/null
+++ b/src/Internal/Operations/Transforming/Remove.php
@@ -0,0 +1,30 @@
+ $value) {
+ if (!Equality::areSame(element: $this->element, other: $value)) {
+ yield $key => $value;
+ }
+ }
+ }
+}
diff --git a/src/Internal/Operations/Transforming/RemoveAll.php b/src/Internal/Operations/Transforming/RemoveAll.php
new file mode 100644
index 0000000..fbac41e
--- /dev/null
+++ b/src/Internal/Operations/Transforming/RemoveAll.php
@@ -0,0 +1,32 @@
+ $value) {
+ if ($this->predicate === null || ($this->predicate)($value)) {
+ continue;
+ }
+
+ yield $key => $value;
+ }
+ }
+}
diff --git a/src/Internal/Operations/Transforming/Segment.php b/src/Internal/Operations/Transforming/Segment.php
new file mode 100644
index 0000000..b8c8f62
--- /dev/null
+++ b/src/Internal/Operations/Transforming/Segment.php
@@ -0,0 +1,69 @@
+length === 0) {
+ return;
+ }
+
+ if ($this->length < -1) {
+ yield from $this->withTrailingBuffer($elements);
+ return;
+ }
+
+ $currentIndex = 0;
+ $emitted = 0;
+
+ foreach ($elements as $key => $value) {
+ if ($currentIndex++ < $this->offset) {
+ continue;
+ }
+
+ yield $key => $value;
+ $emitted++;
+
+ if ($this->length !== -1 && $emitted >= $this->length) {
+ return;
+ }
+ }
+ }
+
+ private function withTrailingBuffer(iterable $elements): Generator
+ {
+ $buffer = new SplQueue();
+ $skipFromEnd = abs($this->length);
+ $currentIndex = 0;
+
+ foreach ($elements as $key => $value) {
+ if ($currentIndex++ < $this->offset) {
+ continue;
+ }
+
+ $buffer->enqueue([$key, $value]);
+
+ if ($buffer->count() > $skipFromEnd) {
+ [$yieldKey, $yieldValue] = $buffer->dequeue();
+ yield $yieldKey => $yieldValue;
+ }
+ }
+ }
+}
diff --git a/src/Internal/Operations/Write/Add.php b/src/Internal/Operations/Write/Add.php
deleted file mode 100644
index 806b060..0000000
--- a/src/Internal/Operations/Write/Add.php
+++ /dev/null
@@ -1,31 +0,0 @@
-newElements as $element) {
- yield $element;
- }
- }
-}
diff --git a/src/Internal/Operations/Write/Create.php b/src/Internal/Operations/Write/Create.php
deleted file mode 100644
index ef8c04d..0000000
--- a/src/Internal/Operations/Write/Create.php
+++ /dev/null
@@ -1,21 +0,0 @@
-otherElements as $element) {
- yield $element;
- }
- }
-}
diff --git a/src/Internal/Operations/Write/Remove.php b/src/Internal/Operations/Write/Remove.php
deleted file mode 100644
index 20fe7d2..0000000
--- a/src/Internal/Operations/Write/Remove.php
+++ /dev/null
@@ -1,32 +0,0 @@
-compareWith(element: $this->element, otherElement: $element)) {
- yield $element;
- }
- }
- }
-}
diff --git a/src/Internal/Operations/Write/RemoveAll.php b/src/Internal/Operations/Write/RemoveAll.php
deleted file mode 100644
index 9b05289..0000000
--- a/src/Internal/Operations/Write/RemoveAll.php
+++ /dev/null
@@ -1,32 +0,0 @@
-filter === null || ($this->filter)($element)) {
- continue;
- }
-
- yield $element;
- }
- }
-}
diff --git a/src/Internal/Pipeline.php b/src/Internal/Pipeline.php
new file mode 100644
index 0000000..5f15edb
--- /dev/null
+++ b/src/Internal/Pipeline.php
@@ -0,0 +1,56 @@
+getIterator();
-
- /** @And calling a method that does not modify the collection */
- $count = $collection->count();
-
- /** @Then the iterator should not be the same (due to lazy generation) */
- self::assertSame(3, $count);
- self::assertNotSame($iterator, $collection->getIterator());
-
- /** @And the collection should remain unchanged */
- self::assertSame([1, 2, 3], iterator_to_array($collection->getIterator()));
- }
-
- public function testIteratorShouldBeRecreatedAfterModification(): void
- {
- /** @Given a collection with elements */
- $collection = Collection::createFrom(elements: [1, 2, 3]);
-
- /** @When retrieving the iterator for the first time */
- $iterator = $collection->getIterator();
-
- /** @And modifying the collection */
- $collection->add(elements: 4);
-
- /** @Then the iterator should be recreated */
- self::assertSame(4, $collection->count());
- self::assertNotSame($iterator, $collection->getIterator());
-
- /** @And the elements should be correct after modification */
- self::assertSame([1, 2, 3, 4], iterator_to_array($collection->getIterator()));
- }
-
- public function testIteratorRemainsUnchangedWithUnrelatedOperations(): void
- {
- /** @Given a collection with elements */
- $collection = Collection::createFrom(elements: [1, 2, 3]);
-
- /** @When performing operations that do not modify the collection */
- $firstIterator = $collection->getIterator();
-
- /** @Then the iterator should remain unchanged */
- self::assertFalse($collection->isEmpty());
- self::assertSame([1, 2, 3], iterator_to_array($firstIterator));
- self::assertSame([1, 2, 3], iterator_to_array($collection->getIterator()));
- }
-}
diff --git a/tests/CollectionPerformanceTest.php b/tests/CollectionPerformanceTest.php
deleted file mode 100644
index 6a2ac27..0000000
--- a/tests/CollectionPerformanceTest.php
+++ /dev/null
@@ -1,297 +0,0 @@
- new Amount(value: $value, currency: Currency::USD),
- $elements
- );
- usort($array, static fn(Amount $first, Amount $second): int => $first->value <=> $second->value);
-
- /** @And performing the same operations with Collection */
- $collection = Collection::createFrom(elements: $elements)
- ->map(static fn(int $value): Amount => new Amount(value: $value, currency: Currency::USD))
- ->sort(
- order: Order::ASCENDING_VALUE,
- predicate: static fn(Amount $first, Amount $second): int => $first->value <=> $second->value
- );
-
- /** @Then assert that both approaches produce the same results */
- self::assertEquals($array[0]->value, $collection->first()->value);
- self::assertEquals($array[array_key_last($array)]->value, $collection->last()->value);
- }
-
- public function testCollectionIsEfficientForFirstElementRetrieval(): void
- {
- /** @Given a large dataset with 10 million elements as a generator */
- $createGenerator = static fn(): Generator => (static function (): Generator {
- for ($index = 1; $index <= 10_000_000; $index++) {
- yield $index;
- }
- })();
-
- /** @When retrieving the first element matching a condition near the beginning */
- $this->forceGarbageCollection();
- $startTime = hrtime(true);
- $startMemory = memory_get_usage(true);
-
- /** @And filter for elements greater than 100 and get the first one (match at position 101) */
- $firstElement = Collection::createFrom(elements: $createGenerator())
- ->filter(static fn(int $value): bool => $value > 100)
- ->first();
-
- /** @And end the time and memory measurement */
- $executionTimeInMs = (hrtime(true) - $startTime) / 1_000_000;
- $memoryUsageInMB = (memory_get_usage(true) - $startMemory) / 1024 / 1024;
-
- /** @Then assert that the first matching element is found */
- self::assertSame(101, $firstElement);
-
- /** @And assert that execution time is minimal due to early termination */
- self::assertLessThan(
- 50.0,
- $executionTimeInMs,
- sprintf('Execution time %.2fms exceeded 50ms limit', $executionTimeInMs)
- );
-
- /** @And assert that memory usage is minimal due to not materializing all elements */
- self::assertLessThan(
- 2.0,
- $memoryUsageInMB,
- sprintf('Memory usage %.2fMB exceeded 2MB limit', $memoryUsageInMB)
- );
- }
-
- public function testLazyOperationsDoNotMaterializeEntireCollection(): void
- {
- /** @Given a generator that would use massive memory if fully materialized */
- $createLargeGenerator = static fn(): Generator => (static function (): Generator {
- for ($index = 1; $index <= 10_000_000; $index++) {
- yield $index;
- }
- })();
-
- /** @When applying multiple transformations and getting only the first element */
- $this->forceGarbageCollection();
- $startMemory = memory_get_usage(true);
-
- /** @And chain filter and map operations */
- $result = Collection::createFrom(elements: $createLargeGenerator())
- ->filter(static fn(int $value): bool => $value % 2 === 0)
- ->map(static fn(int $value): int => $value * 10)
- ->filter(static fn(int $value): bool => $value > 100)
- ->first();
-
- /** @And measure memory after operation */
- $memoryUsageInMB = (memory_get_usage(true) - $startMemory) / 1024 / 1024;
-
- /** @Then assert that the correct result is returned */
- self::assertSame(120, $result);
-
- /** @And assert that memory usage is minimal (not 10 million integers in memory) */
- self::assertLessThan(
- 10.0,
- $memoryUsageInMB,
- sprintf(
- 'Memory usage %.2fMB is too high - collection may be materializing unnecessarily',
- $memoryUsageInMB
- )
- );
- }
-
- public function testCollectionFindByIsEfficientWithEarlyTermination(): void
- {
- /** @Given a large dataset with 1 million elements as a generator */
- $createGenerator = static fn(): Generator => (static function (): Generator {
- for ($index = 1; $index <= 1_000_000; $index++) {
- yield new Amount(value: $index, currency: Currency::USD);
- }
- })();
-
- /** @When finding the first element with value 100 using Collection */
- $this->forceGarbageCollection();
- $startTime = hrtime(true);
- $startMemory = memory_get_usage(true);
-
- /** @And use findBy to locate the element */
- $foundElement = Collection::createFrom(elements: $createGenerator())
- ->findBy(static fn(Amount $amount): bool => $amount->value == 100.0);
-
- /** @And end the time and memory measurement */
- $executionTimeInMs = (hrtime(true) - $startTime) / 1_000_000;
- $memoryUsageInMB = (memory_get_usage(true) - $startMemory) / 1024 / 1024;
-
- /** @Then assert that the correct element is found */
- self::assertSame(100.0, $foundElement->value);
-
- /** @And assert that execution time is minimal due to early termination */
- self::assertLessThan(
- 100.0,
- $executionTimeInMs,
- sprintf('Execution time %.2fms exceeded 100ms limit', $executionTimeInMs)
- );
-
- /** @And assert that memory usage is minimal */
- self::assertLessThan(
- 2.0,
- $memoryUsageInMB,
- sprintf('Memory usage %.2fMB exceeded 2MB limit', $memoryUsageInMB)
- );
- }
-
- public function testChainedOperationsPerformanceAndMemoryWithCollection(): void
- {
- /** @Given a large collection of Amount objects containing 100 thousand elements */
- $collection = Collection::createFrom(
- elements: (static function (): Generator {
- foreach (range(1, 100_000) as $value) {
- yield new Amount(value: $value, currency: Currency::USD);
- }
- })()
- );
-
- /** @And start measuring time and memory usage */
- $this->forceGarbageCollection();
- $startTime = hrtime(true);
- $startMemory = memory_get_usage(true);
-
- /**
- * @When performing the following chained operations:
- * filtering to retain the first 50,000 elements,
- * mapping to double each Amount's value,
- * filtering to retain the first 45,000 elements,
- * further filtering to retain the first 35,000 elements,
- * mapping to double each Amount's value again,
- * filtering to retain the first 30,000 elements,
- * further filtering to retain the first 10,000 elements,
- * mapping to convert the currency from USD to BRL and adjusting the value by a factor of 5.5,
- * sorting the collection in descending order by value.
- */
- $result = $collection
- ->filter(static fn(Amount $amount, int $index): bool => $index < 50_000)
- ->map(static fn(Amount $amount): Amount => new Amount(
- value: $amount->value * 2,
- currency: $amount->currency
- ))
- ->filter(static fn(Amount $amount, int $index): bool => $index < 45_000)
- ->filter(static fn(Amount $amount, int $index): bool => $index < 35_000)
- ->map(static fn(Amount $amount): Amount => new Amount(
- value: $amount->value * 2,
- currency: $amount->currency
- ))
- ->filter(static fn(Amount $amount, int $index): bool => $index < 30_000)
- ->filter(static fn(Amount $amount, int $index): bool => $index < 10_000)
- ->map(static fn(Amount $amount): Amount => new Amount(
- value: $amount->value * 5.5,
- currency: Currency::BRL
- ))
- ->sort(order: Order::DESCENDING_VALUE);
-
- /** @And force full evaluation by getting first element */
- $firstElement = $result->first();
-
- /** @And end measuring time and memory usage */
- $executionTimeInSeconds = (hrtime(true) - $startTime) / 1_000_000_000;
- $memoryUsageInMB = (memory_get_usage(true) - $startMemory) / 1024 / 1024;
-
- /** @Then verify the value of the first element in the sorted collection */
- self::assertSame(220_000.0, $firstElement->value);
-
- /** @And verify that the total duration of the chained operations is within limits */
- self::assertLessThan(
- 15.0,
- $executionTimeInSeconds,
- sprintf('Execution time %.2fs exceeded 15s limit', $executionTimeInSeconds)
- );
-
- /** @And verify that memory usage is within acceptable limits */
- self::assertLessThan(
- 50.0,
- $memoryUsageInMB,
- sprintf('Memory usage %.2fMB exceeded 50MB limit', $memoryUsageInMB)
- );
- }
-
- public function testCollectionUsesLessMemoryThanArrayForFirstElementRetrieval(): void
- {
- /** @Given a large dataset with 1 million elements */
- $totalElements = 1_000_000;
- $targetValue = 1000;
-
- /** @When finding the first matching element with an array */
- $this->forceGarbageCollection();
- $startArrayMemory = memory_get_usage(true);
-
- /** @And create array and find first element greater than target */
- $array = range(1, $totalElements);
- $arrayResult = null;
- foreach ($array as $value) {
- if ($value > $targetValue) {
- $arrayResult = $value;
- break;
- }
- }
-
- /** @And measure memory usage for the array operations */
- $arrayMemoryUsageInBytes = memory_get_usage(true) - $startArrayMemory;
-
- /** @And clean up array memory before Collection test */
- unset($array);
- $this->forceGarbageCollection();
-
- /** @When finding the first matching element with Collection using a generator */
- $startCollectionMemory = memory_get_usage(true);
-
- /** @And create collection from generator and find first element greater than target */
- $generator = (static function () use ($totalElements): Generator {
- for ($index = 1; $index <= $totalElements; $index++) {
- yield $index;
- }
- })();
-
- $collectionResult = Collection::createFrom(elements: $generator)
- ->filter(static fn(int $value): bool => $value > $targetValue)
- ->first();
-
- /** @And measure memory usage for the Collection operations */
- $collectionMemoryUsageInBytes = memory_get_usage(true) - $startCollectionMemory;
-
- /** @Then assert that both approaches produce the same result */
- self::assertSame($arrayResult, $collectionResult);
-
- /** @And assert that Collection uses less memory than array */
- self::assertLessThan(
- $arrayMemoryUsageInBytes,
- $collectionMemoryUsageInBytes,
- sprintf(
- 'Collection (%d bytes) should use less memory than array (%d bytes)',
- $collectionMemoryUsageInBytes,
- $arrayMemoryUsageInBytes
- )
- );
- }
-
- private function forceGarbageCollection(): void
- {
- gc_enable();
- gc_collect_cycles();
- gc_mem_caches();
- }
-}
diff --git a/tests/CollectionTest.php b/tests/CollectionTest.php
index 84333fa..0453aa1 100644
--- a/tests/CollectionTest.php
+++ b/tests/CollectionTest.php
@@ -6,81 +6,603 @@
use PHPUnit\Framework\TestCase;
use Test\TinyBlocks\Collection\Models\Amount;
+use Test\TinyBlocks\Collection\Models\Carriers;
+use Test\TinyBlocks\Collection\Models\CryptoCurrency;
+use Test\TinyBlocks\Collection\Models\Dragon;
+use Test\TinyBlocks\Collection\Models\Invoice;
+use Test\TinyBlocks\Collection\Models\Invoices;
+use Test\TinyBlocks\Collection\Models\InvoiceSummaries;
+use Test\TinyBlocks\Collection\Models\InvoiceSummary;
+use Test\TinyBlocks\Collection\Models\Order;
+use Test\TinyBlocks\Collection\Models\Product;
+use Test\TinyBlocks\Collection\Models\Products;
+use Test\TinyBlocks\Collection\Models\Status;
use TinyBlocks\Collection\Collection;
-use TinyBlocks\Collection\Order;
+use TinyBlocks\Collection\Order as SortOrder;
use TinyBlocks\Currency\Currency;
+use TinyBlocks\Mapper\KeyPreservation;
final class CollectionTest extends TestCase
{
- public function testChainedOperationsWithObjects(): void
+ public function testLazyAndEagerProduceSameResultsWithIntegerPipeline(): void
{
- /** @Given a collection of Amount objects */
- $collection = Collection::createFrom(elements: [
+ /** @Given a set of elements */
+ $elements = [5, 3, 1, 4, 2];
+
+ /** @And a filter predicate for values greater than 2 */
+ $filter = static fn(int $value): bool => $value > 2;
+
+ /** @And a map transformation that multiplies by 10 */
+ $map = static fn(int $value): int => $value * 10;
+
+ /** @When applying filter, map and sort on a lazy collection */
+ $lazyResult = Collection::createLazyFrom(elements: $elements)
+ ->filter(predicates: $filter)
+ ->map(transformations: $map)
+ ->sort(order: SortOrder::ASCENDING_VALUE)
+ ->toArray(keyPreservation: KeyPreservation::DISCARD);
+
+ /** @And applying the same operations on an eager collection */
+ $eagerResult = Collection::createFrom(elements: $elements)
+ ->filter(predicates: $filter)
+ ->map(transformations: $map)
+ ->sort(order: SortOrder::ASCENDING_VALUE)
+ ->toArray(keyPreservation: KeyPreservation::DISCARD);
+
+ /** @Then both collections should produce identical arrays */
+ self::assertSame($lazyResult, $eagerResult);
+ }
+
+ public function testLazyAndEagerProduceSameResultsWithObjectPipeline(): void
+ {
+ /** @Given a set of Amount objects */
+ $elements = [
new Amount(value: 50.00, currency: Currency::USD),
new Amount(value: 100.00, currency: Currency::USD),
new Amount(value: 150.00, currency: Currency::USD),
new Amount(value: 250.00, currency: Currency::USD),
new Amount(value: 500.00, currency: Currency::USD)
+ ];
+
+ /** @And a filter predicate for amounts greater than or equal to 100 */
+ $filter = static fn(Amount $amount): bool => $amount->value >= 100;
+
+ /** @And a map transformation that applies a 10% discount */
+ $map = static fn(Amount $amount): Amount => new Amount(
+ value: $amount->value * 0.9,
+ currency: $amount->currency
+ );
+
+ /** @And a removeAll predicate for amounts greater than 300 */
+ $removeAll = static fn(Amount $amount): bool => $amount->value > 300;
+
+ /** @And a comparator that sorts by value */
+ $comparator = static fn(Amount $first, Amount $second): int => $first->value <=> $second->value;
+
+ /** @When applying the operations on a lazy collection */
+ $lazy = Collection::createLazyFrom(elements: $elements)
+ ->filter(predicates: $filter)
+ ->map(transformations: $map)
+ ->removeAll(predicate: $removeAll)
+ ->sort(order: SortOrder::ASCENDING_VALUE, comparator: $comparator);
+
+ /** @And applying the same operations on an eager collection */
+ $eager = Collection::createFrom(elements: $elements)
+ ->filter(predicates: $filter)
+ ->map(transformations: $map)
+ ->removeAll(predicate: $removeAll)
+ ->sort(order: SortOrder::ASCENDING_VALUE, comparator: $comparator);
+
+ /** @Then both should have the same count */
+ self::assertSame($lazy->count(), $eager->count());
+
+ /** @And the same first value */
+ self::assertSame($lazy->first()->value, $eager->first()->value);
+
+ /** @And the same last value */
+ self::assertSame($lazy->last()->value, $eager->last()->value);
+ }
+
+ public function testConcatLazyWithEager(): void
+ {
+ /** @Given a lazy collection */
+ $lazy = Collection::createLazyFrom(elements: [1, 2]);
+
+ /** @And an eager collection */
+ $eager = Collection::createFrom(elements: [3, 4]);
+
+ /** @When concatenating the eager collection into the lazy one */
+ $actual = $lazy->merge(other: $eager);
+
+ /** @Then the resulting collection should contain all four elements */
+ self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testConcatEagerWithLazy(): void
+ {
+ /** @Given an eager collection */
+ $eager = Collection::createFrom(elements: [1, 2]);
+
+ /** @And a lazy collection */
+ $lazy = Collection::createLazyFrom(elements: [3, 4]);
+
+ /** @When concatenating the lazy collection into the eager one */
+ $actual = $eager->merge(other: $lazy);
+
+ /** @Then the resulting collection should contain all four elements */
+ self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testEqualsAcrossStrategies(): void
+ {
+ /** @Given a lazy collection with elements 1, 2, 3 */
+ $lazy = Collection::createLazyFrom(elements: [1, 2, 3]);
+
+ /** @And an eager collection with elements 1, 2, 3 */
+ $eager = Collection::createFrom(elements: [1, 2, 3]);
+
+ /** @When comparing lazy equals eager */
+ $lazyEqualsEager = $lazy->equals(other: $eager);
+
+ /** @And comparing eager equals lazy */
+ $eagerEqualsLazy = $eager->equals(other: $lazy);
+
+ /** @Then the lazy-to-eager comparison should return true */
+ self::assertTrue($lazyEqualsEager);
+
+ /** @And the eager-to-lazy comparison should return true */
+ self::assertTrue($eagerEqualsLazy);
+ }
+
+ public function testReduceProducesSameResultAcrossStrategies(): void
+ {
+ /** @Given a set of elements */
+ $elements = [1, 2, 3, 4, 5];
+
+ /** @And an accumulator that sums values */
+ $accumulator = static fn(int $carry, int $value): int => $carry + $value;
+
+ /** @When reducing with a lazy collection */
+ $lazySum = Collection::createLazyFrom(elements: $elements)
+ ->reduce(accumulator: $accumulator, initial: 0);
+
+ /** @And reducing with an eager collection */
+ $eagerSum = Collection::createFrom(elements: $elements)
+ ->reduce(accumulator: $accumulator, initial: 0);
+
+ /** @Then the lazy sum should be 15 */
+ self::assertSame(15, $lazySum);
+
+ /** @And the eager sum should be 15 */
+ self::assertSame(15, $eagerSum);
+ }
+
+ public function testCarriersPreservesTypeAfterFilter(): void
+ {
+ /** @Given a Carriers collection with three carrier names */
+ $carriers = Carriers::createFrom(elements: ['DHL', 'FedEx', 'UPS']);
+
+ /** @When filtering carriers that start with a letter after D */
+ $actual = $carriers->filter(
+ predicates: static fn(string $name): bool => $name[0] >= 'E'
+ );
+
+ /** @Then the result should still be an instance of Carriers */
+ self::assertInstanceOf(Carriers::class, $actual);
+
+ /** @And it should contain two carriers */
+ self::assertSame(2, $actual->count());
+
+ /** @And the carriers should be FedEx and UPS */
+ self::assertSame(['FedEx', 'UPS'], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testCarriersPreservesTypeAfterAdd(): void
+ {
+ /** @Given a Carriers collection with two carrier names */
+ $carriers = Carriers::createFrom(elements: ['DHL', 'FedEx']);
+
+ /** @When adding a new carrier */
+ $actual = $carriers->add('Correios');
+
+ /** @Then the result should still be an instance of Carriers */
+ self::assertInstanceOf(Carriers::class, $actual);
+
+ /** @And it should contain three carriers */
+ self::assertSame(3, $actual->count());
+ }
+
+ public function testCarriersLazyPreservesTypeAfterMap(): void
+ {
+ /** @Given a lazy Carriers collection */
+ $carriers = Carriers::createLazyFrom(elements: ['dhl', 'fedex', 'ups']);
+
+ /** @When mapping to uppercase */
+ $actual = $carriers->map(
+ transformations: static fn(string $name): string => strtoupper($name)
+ );
+
+ /** @Then the result should still be an instance of Carriers */
+ self::assertInstanceOf(Carriers::class, $actual);
+
+ /** @And the carriers should be uppercased */
+ self::assertSame(['DHL', 'FEDEX', 'UPS'], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testInvoicesTotalAmount(): void
+ {
+ /** @Given a set of invoices */
+ $invoices = Invoices::createFrom(elements: [
+ new Invoice(id: 'INV-001', amount: 100.00, customer: 'Alice'),
+ new Invoice(id: 'INV-002', amount: 200.00, customer: 'Bob'),
+ new Invoice(id: 'INV-003', amount: 150.00, customer: 'Alice')
+ ]);
+
+ /** @When calculating the total amount */
+ $total = $invoices->totalAmount();
+
+ /** @Then the total should be 450 */
+ self::assertSame(450.00, $total);
+ }
+
+ public function testInvoicesFilterByCustomer(): void
+ {
+ /** @Given a set of invoices for different customers */
+ $invoices = Invoices::createFrom(elements: [
+ new Invoice(id: 'INV-001', amount: 100.00, customer: 'Alice'),
+ new Invoice(id: 'INV-002', amount: 200.00, customer: 'Bob'),
+ new Invoice(id: 'INV-003', amount: 150.00, customer: 'Alice')
+ ]);
+
+ /** @When filtering invoices for Alice */
+ $aliceInvoices = $invoices->forCustomer(customer: 'Alice');
+
+ /** @Then the result should still be an instance of Invoices */
+ self::assertInstanceOf(Invoices::class, $aliceInvoices);
+
+ /** @And Alice should have two invoices */
+ self::assertSame(2, $aliceInvoices->count());
+
+ /** @And the total for Alice should be 250 */
+ self::assertSame(250.00, $aliceInvoices->totalAmount());
+ }
+
+ public function testInvoiceSummariesSumByCustomer(): void
+ {
+ /** @Given a collection of invoice summaries */
+ $summaries = InvoiceSummaries::createFrom(elements: [
+ new InvoiceSummary(amount: 100.00, customer: 'Alice'),
+ new InvoiceSummary(amount: 200.00, customer: 'Bob'),
+ new InvoiceSummary(amount: 150.00, customer: 'Alice'),
+ new InvoiceSummary(amount: 300.00, customer: 'Bob')
+ ]);
+
+ /** @When summing by customer Alice */
+ $aliceTotal = $summaries->sumByCustomer(customer: 'Alice');
+
+ /** @Then Alice's total should be 250 */
+ self::assertSame(250.00, $aliceTotal);
+ }
+
+ public function testInvoiceSummariesSumByDifferentCustomer(): void
+ {
+ /** @Given a collection of invoice summaries */
+ $summaries = InvoiceSummaries::createFrom(elements: [
+ new InvoiceSummary(amount: 100.00, customer: 'Alice'),
+ new InvoiceSummary(amount: 200.00, customer: 'Bob'),
+ new InvoiceSummary(amount: 150.00, customer: 'Alice'),
+ new InvoiceSummary(amount: 300.00, customer: 'Bob')
+ ]);
+
+ /** @When summing by customer Bob */
+ $bobTotal = $summaries->sumByCustomer(customer: 'Bob');
+
+ /** @Then Bob's total should be 500 */
+ self::assertSame(500.00, $bobTotal);
+ }
+
+ public function testInvoiceSummariesSumByNonExistentCustomer(): void
+ {
+ /** @Given a collection of invoice summaries */
+ $summaries = InvoiceSummaries::createFrom(elements: [
+ new InvoiceSummary(amount: 100.00, customer: 'Alice')
]);
- /** @When chaining multiple operations:
- * filter amounts greater than or equal to 100,
- * apply a 10% discount,
- * remove amounts greater than 300 after the discount,
- * sort amounts in ascending order,
- * and use each to accumulate the total discounted value */
- $totalDiscounted = 0;
- $actual = $collection
- ->filter(predicates: static fn(Amount $amount): bool => $amount->value >= 100)
- ->map(transformations: static fn(Amount $amount): Amount => new Amount(
- value: $amount->value * 0.9,
- currency: $amount->currency
- ))
- ->removeAll(filter: static fn(Amount $amount): bool => $amount->value > 300)
- ->sort(order: Order::ASCENDING_VALUE, predicate: static fn(
- Amount $first,
- Amount $second
- ): int => $first->value <=> $second->value)
- ->each(actions: function (Amount $amount) use (&$totalDiscounted) {
- $totalDiscounted += $amount->value;
- });
-
- /** @Then the final collection should contain exactly three elements */
- self::assertCount(3, $actual);
-
- /** @And the total discounted value should be calculated correctly */
- self::assertSame(450.00, $totalDiscounted);
-
- /** @And the first Amount should be 90 after the discount */
- self::assertSame(90.00, $actual->first()->value);
-
- /** @And the last Amount should be 225 after the discount */
- self::assertSame(225.00, $actual->last()->value);
- }
-
- public function testChainedOperationsWithIntegers(): void
- {
- /** @Given a collection of integers from 1 to 100 */
- $collection = Collection::createFrom(elements: range(1, 100));
-
- /** @When filtering even numbers,
- * Then mapping each number to its square,
- * And sorting the squared numbers in descending order */
- $actual = $collection
- ->filter(predicates: static fn(int $value): bool => $value % 2 === 0)
- ->map(transformations: static fn(int $value): int => $value ** 2)
- ->sort(order: Order::DESCENDING_VALUE);
-
- /** @Then the first element after sorting should be 10,000 (square of 100) */
- self::assertSame(10000, $actual->first());
-
- /** @And the last element after sorting should be four (square of 2) */
- self::assertSame(4, $actual->last());
-
- /** @When reducing the collection to calculate the sum of all squared even numbers */
- $sum = $actual->reduce(aggregator: static fn(int $carry, int $value): int => $carry + $value, initial: 0);
-
- /** @Then the sum of squared even numbers should be correct (171700) */
- self::assertSame(171700, $sum);
+ /** @When summing by a customer that does not exist */
+ $total = $summaries->sumByCustomer(customer: 'Charlie');
+
+ /** @Then the total should be zero */
+ self::assertSame(0.0, $total);
+ }
+
+ public function testDragonsGroupByDescription(): void
+ {
+ /** @Given a collection of dragons */
+ $dragons = Collection::createFrom(elements: [
+ new Dragon(name: 'Smaug', description: 'fire'),
+ new Dragon(name: 'Viserion', description: 'ice'),
+ new Dragon(name: 'Drogon', description: 'fire'),
+ new Dragon(name: 'Rhaegal', description: 'fire'),
+ new Dragon(name: 'Frostfyre', description: 'ice')
+ ]);
+
+ /** @When grouping by description */
+ $grouped = $dragons->groupBy(
+ classifier: static fn(Dragon $dragon): string => $dragon->description
+ );
+
+ /** @Then the fire group should contain three dragons */
+ $groups = $grouped->toArray();
+ self::assertCount(3, $groups['fire']);
+
+ /** @And the ice group should contain two dragons */
+ self::assertCount(2, $groups['ice']);
+ }
+
+ public function testDragonsFindByName(): void
+ {
+ /** @Given a collection of dragons */
+ $dragons = Collection::createFrom(elements: [
+ new Dragon(name: 'Smaug', description: 'fire'),
+ new Dragon(name: 'Viserion', description: 'ice'),
+ new Dragon(name: 'Drogon', description: 'fire')
+ ]);
+
+ /** @When finding the dragon named Viserion */
+ $actual = $dragons->findBy(
+ predicates: static fn(Dragon $dragon): bool => $dragon->name === 'Viserion'
+ );
+
+ /** @Then it should return Viserion */
+ self::assertSame('Viserion', $actual->name);
+
+ /** @And Viserion should be an ice dragon */
+ self::assertSame('ice', $actual->description);
+ }
+
+ public function testCryptoCurrencySortByPrice(): void
+ {
+ /** @Given a collection of crypto currencies */
+ $cryptos = Collection::createFrom(elements: [
+ new CryptoCurrency(name: 'Bitcoin', price: 50000.00, symbol: 'BTC'),
+ new CryptoCurrency(name: 'Ethereum', price: 3000.00, symbol: 'ETH'),
+ new CryptoCurrency(name: 'Solana', price: 150.00, symbol: 'SOL')
+ ]);
+
+ /** @When sorting by price in ascending order */
+ $sorted = $cryptos->sort(
+ order: SortOrder::ASCENDING_VALUE,
+ comparator: static fn(
+ CryptoCurrency $first,
+ CryptoCurrency $second
+ ): int => $first->price <=> $second->price
+ );
+
+ /** @Then the cheapest should be Solana */
+ self::assertSame('SOL', $sorted->first()->symbol);
+
+ /** @And the most expensive should be Bitcoin */
+ self::assertSame('BTC', $sorted->last()->symbol);
+ }
+
+ public function testCryptoCurrencyFilterAndMapSymbols(): void
+ {
+ /** @Given a collection of crypto currencies */
+ $cryptos = Collection::createFrom(elements: [
+ new CryptoCurrency(name: 'Bitcoin', price: 50000.00, symbol: 'BTC'),
+ new CryptoCurrency(name: 'Ethereum', price: 3000.00, symbol: 'ETH'),
+ new CryptoCurrency(name: 'Dogecoin', price: 0.08, symbol: 'DOGE'),
+ new CryptoCurrency(name: 'Solana', price: 150.00, symbol: 'SOL')
+ ]);
+
+ /** @When filtering currencies above 100 and mapping to symbols */
+ $symbols = $cryptos
+ ->filter(predicates: static fn(CryptoCurrency $crypto): bool => $crypto->price > 100)
+ ->map(transformations: static fn(CryptoCurrency $crypto): string => $crypto->symbol)
+ ->sort(order: SortOrder::ASCENDING_VALUE);
+
+ /** @Then the symbols should be BTC, ETH, SOL in alphabetical order */
+ self::assertSame(['BTC', 'ETH', 'SOL'], $symbols->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testProductsWithAmountFlattenFromOrders(): void
+ {
+ /** @Given a set of orders with products */
+ $orders = Collection::createFrom(elements: [
+ new Order(id: 1, products: new Products(elements: [
+ new Product(name: 'Keyboard', amount: new Amount(value: 75.00, currency: Currency::USD)),
+ new Product(name: 'Mouse', amount: new Amount(value: 25.00, currency: Currency::USD))
+ ])),
+ new Order(id: 2, products: new Products(elements: [
+ new Product(name: 'Monitor', amount: new Amount(value: 500.00, currency: Currency::USD))
+ ]))
+ ]);
+
+ /** @When extracting all products and flattening */
+ $allProducts = $orders
+ ->map(transformations: static fn(Order $order): array => iterator_to_array($order->products))
+ ->flatten();
+
+ /** @Then there should be three products */
+ self::assertSame(3, $allProducts->count());
+
+ /** @And the total cost should be 600 */
+ $total = $allProducts->reduce(
+ accumulator: static fn(float $carry, Product $product): float => $carry + $product->amount->value,
+ initial: 0.0
+ );
+ self::assertSame(600.00, $total);
+ }
+
+ public function testStatusEnumCollection(): void
+ {
+ /** @Given a collection of Status enums */
+ $statuses = Collection::createFrom(elements: [
+ Status::PAID,
+ Status::PENDING,
+ Status::PAID,
+ Status::PAID,
+ Status::PENDING
+ ]);
+
+ /** @When filtering only PAID statuses */
+ $paid = $statuses->filter(
+ predicates: static fn(Status $status): bool => $status === Status::PAID
+ );
+
+ /** @Then there should be three PAID statuses */
+ self::assertSame(3, $paid->count());
+ }
+
+ public function testStatusEnumGroupBy(): void
+ {
+ /** @Given a collection of Status enums */
+ $statuses = Collection::createFrom(elements: [
+ Status::PAID,
+ Status::PENDING,
+ Status::PAID,
+ Status::PAID,
+ Status::PENDING
+ ]);
+
+ /** @When grouping by status name */
+ $grouped = $statuses->groupBy(
+ classifier: static fn(Status $status): string => $status->name
+ );
+
+ /** @Then the PAID group should have three entries */
+ $groups = $grouped->toArray();
+ self::assertCount(3, $groups['PAID']);
+
+ /** @And the PENDING group should have two entries */
+ self::assertCount(2, $groups['PENDING']);
+ }
+
+ public function testInvoicesLazyStrategyPreservesType(): void
+ {
+ /** @Given a lazy Invoices collection */
+ $invoices = Invoices::createLazyFrom(elements: [
+ new Invoice(id: 'INV-001', amount: 100.00, customer: 'Alice'),
+ new Invoice(id: 'INV-002', amount: 200.00, customer: 'Bob')
+ ]);
+
+ /** @When sorting by amount */
+ $sorted = $invoices->sort(
+ order: SortOrder::DESCENDING_VALUE,
+ comparator: static fn(Invoice $first, Invoice $second): int => $first->amount <=> $second->amount
+ );
+
+ /** @Then the result should still be an instance of Invoices */
+ self::assertInstanceOf(Invoices::class, $sorted);
+
+ /** @And the first invoice should have the highest amount */
+ self::assertSame(200.00, $sorted->first()->amount);
+ }
+
+ public function testInvoicesSliceAndCount(): void
+ {
+ /** @Given a set of five invoices */
+ $invoices = Invoices::createFrom(elements: [
+ new Invoice(id: 'INV-001', amount: 100.00, customer: 'Alice'),
+ new Invoice(id: 'INV-002', amount: 200.00, customer: 'Bob'),
+ new Invoice(id: 'INV-003', amount: 300.00, customer: 'Charlie'),
+ new Invoice(id: 'INV-004', amount: 400.00, customer: 'Alice'),
+ new Invoice(id: 'INV-005', amount: 500.00, customer: 'Bob')
+ ]);
+
+ /** @When slicing from offset 1 with length 3 */
+ $sliced = $invoices->slice(offset: 1, length: 3);
+
+ /** @Then the result should still be an instance of Invoices */
+ self::assertInstanceOf(Invoices::class, $sliced);
+
+ /** @And the sliced collection should have three invoices */
+ self::assertSame(3, $sliced->count());
+
+ /** @And the total of the sliced invoices should be 900 */
+ self::assertSame(900.00, $sliced->totalAmount());
+ }
+
+ public function testInvoicesRemoveSpecificInvoice(): void
+ {
+ /** @Given a specific invoice to remove */
+ $toRemove = new Invoice(id: 'INV-002', amount: 200.00, customer: 'Bob');
+
+ /** @And a set of invoices containing that invoice */
+ $invoices = Invoices::createFrom(elements: [
+ new Invoice(id: 'INV-001', amount: 100.00, customer: 'Alice'),
+ $toRemove,
+ new Invoice(id: 'INV-003', amount: 300.00, customer: 'Charlie')
+ ]);
+
+ /** @When removing that invoice */
+ $actual = $invoices->remove(element: $toRemove);
+
+ /** @Then there should be two invoices remaining */
+ self::assertSame(2, $actual->count());
+
+ /** @And the total should be 400 */
+ self::assertSame(400.00, $actual->totalAmount());
+ }
+
+ public function testDragonsJoinToString(): void
+ {
+ /** @Given a collection of dragons */
+ $dragons = Collection::createFrom(elements: [
+ new Dragon(name: 'Smaug', description: 'fire'),
+ new Dragon(name: 'Viserion', description: 'ice'),
+ new Dragon(name: 'Drogon', description: 'fire')
+ ]);
+
+ /** @When mapping to names and joining with a comma */
+ $names = $dragons
+ ->map(transformations: static fn(Dragon $dragon): string => $dragon->name)
+ ->joinToString(separator: ', ');
+
+ /** @Then the result should list all dragon names */
+ self::assertSame('Smaug, Viserion, Drogon', $names);
+ }
+
+ public function testInvoiceSummariesPreservesTypeAfterFilter(): void
+ {
+ /** @Given a collection of invoice summaries */
+ $summaries = InvoiceSummaries::createFrom(elements: [
+ new InvoiceSummary(amount: 100.00, customer: 'Alice'),
+ new InvoiceSummary(amount: 200.00, customer: 'Bob')
+ ]);
+
+ /** @When filtering for Alice */
+ $filtered = $summaries->filter(
+ predicates: static fn(InvoiceSummary $summary): bool => $summary->customer === 'Alice'
+ );
+
+ /** @Then the result should still be an instance of InvoiceSummaries */
+ self::assertInstanceOf(InvoiceSummaries::class, $filtered);
+
+ /** @And it should contain one summary */
+ self::assertSame(1, $filtered->count());
+ }
+
+ public function testConcatCarriersCollections(): void
+ {
+ /** @Given a domestic carriers collection */
+ $domestic = Carriers::createFrom(elements: ['Correios', 'Jadlog']);
+
+ /** @And an international carriers collection */
+ $international = Carriers::createFrom(elements: ['DHL', 'FedEx']);
+
+ /** @When concatenating international into domestic */
+ $all = $domestic->merge(other: $international);
+
+ /** @Then the result should still be an instance of Carriers */
+ self::assertInstanceOf(Carriers::class, $all);
+
+ /** @And it should contain four carriers */
+ self::assertSame(4, $all->count());
+
+ /** @And the carriers should be in the expected order */
+ self::assertSame(['Correios', 'Jadlog', 'DHL', 'FedEx'],
+ $all->toArray(keyPreservation: KeyPreservation::DISCARD)
+ );
}
}
diff --git a/tests/EagerCollectionTest.php b/tests/EagerCollectionTest.php
new file mode 100644
index 0000000..f8de439
--- /dev/null
+++ b/tests/EagerCollectionTest.php
@@ -0,0 +1,1043 @@
+count());
+
+ /** @And the array should match the original elements */
+ self::assertSame([1, 2, 3], $collection->toArray());
+ }
+
+ public function testFromEmpty(): void
+ {
+ /** @When creating an eager collection without arguments */
+ $collection = Collection::createFromEmpty();
+
+ /** @Then the collection should be empty */
+ self::assertTrue($collection->isEmpty());
+
+ /** @And the count should be zero */
+ self::assertSame(0, $collection->count());
+ }
+
+ public function testFromGenerator(): void
+ {
+ /** @Given a generator that yields three elements */
+ $generator = (static function (): Generator {
+ yield 1;
+ yield 2;
+ yield 3;
+ })();
+
+ /** @When creating an eager collection from the generator */
+ $collection = Collection::createFrom(elements: $generator);
+
+ /** @Then the collection should materialize all three elements */
+ self::assertSame(3, $collection->count());
+ }
+
+ public function testAdd(): void
+ {
+ /** @Given an eager collection with three elements */
+ $collection = Collection::createFrom(elements: [1, 2, 3]);
+
+ /** @When adding two more elements */
+ $actual = $collection->add(4, 5);
+
+ /** @Then the new collection should contain five elements */
+ self::assertSame(5, $actual->count());
+
+ /** @And the elements should be in the expected order */
+ self::assertSame([1, 2, 3, 4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+
+ /** @And the original collection should remain unchanged */
+ self::assertSame(3, $collection->count());
+ }
+
+ public function testConcat(): void
+ {
+ /** @Given a first eager collection */
+ $first = Collection::createFrom(elements: [1, 2]);
+
+ /** @And a second eager collection */
+ $second = Collection::createFrom(elements: [3, 4]);
+
+ /** @When concatenating the second into the first */
+ $actual = $first->merge(other: $second);
+
+ /** @Then the resulting collection should contain four elements */
+ self::assertSame(4, $actual->count());
+
+ /** @And the elements should be in the expected order */
+ self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testContainsExistingElement(): void
+ {
+ /** @Given an eager collection with integers */
+ $collection = Collection::createFrom(elements: [1, 2, 3]);
+
+ /** @When checking for an element that exists */
+ $actual = $collection->contains(element: 2);
+
+ /** @Then it should return true */
+ self::assertTrue($actual);
+ }
+
+ public function testContainsMissingElement(): void
+ {
+ /** @Given an eager collection with integers */
+ $collection = Collection::createFrom(elements: [1, 2, 3]);
+
+ /** @When checking for an element that does not exist */
+ $actual = $collection->contains(element: 99);
+
+ /** @Then it should return false */
+ self::assertFalse($actual);
+ }
+
+ public function testContainsObject(): void
+ {
+ /** @Given an Amount object to search for */
+ $target = new Amount(value: 100.00, currency: Currency::USD);
+
+ /** @And an eager collection with Amount objects */
+ $collection = Collection::createFrom(elements: [
+ new Amount(value: 50.00, currency: Currency::USD),
+ new Amount(value: 100.00, currency: Currency::USD),
+ new Amount(value: 200.00, currency: Currency::USD)
+ ]);
+
+ /** @When checking if the collection contains an equivalent Amount */
+ $actual = $collection->contains(element: $target);
+
+ /** @Then it should return true */
+ self::assertTrue($actual);
+ }
+
+ public function testContainsObjectDoesNotMatchTrueScalar(): void
+ {
+ /** @Given an eager collection containing boolean true */
+ $collection = Collection::createFrom(elements: [true]);
+
+ /** @When checking if the collection contains an object */
+ $actual = $collection->contains(element: new stdClass());
+
+ /** @Then it should return false because object and scalar types differ */
+ self::assertFalse($actual);
+ }
+
+ public function testCollectionWithObjectDoesNotContainTrueScalar(): void
+ {
+ /** @Given an eager collection containing a stdClass object */
+ $collection = Collection::createFrom(elements: [new stdClass()]);
+
+ /** @When checking if the collection contains boolean true */
+ $actual = $collection->contains(element: true);
+
+ /** @Then it should return false because an object is not a scalar */
+ self::assertFalse($actual);
+ }
+
+ public function testCount(): void
+ {
+ /** @Given an eager collection with five elements */
+ $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5]);
+
+ /** @When counting the elements */
+ $actual = $collection->count();
+
+ /** @Then it should return 5 */
+ self::assertSame(5, $actual);
+ }
+
+ public function testFindFirstMatch(): void
+ {
+ /** @Given an eager collection of integers */
+ $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5]);
+
+ /** @When finding the first element greater than 3 */
+ $actual = $collection->findBy(predicates: static fn(int $value): bool => $value > 3);
+
+ /** @Then it should return 4 */
+ self::assertSame(4, $actual);
+ }
+
+ public function testFindFirstMatchAcrossMultiplePredicates(): void
+ {
+ /** @Given an eager collection of integers */
+ $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5]);
+
+ /** @When finding by multiple predicates (OR semantics) */
+ $matchTen = static fn(int $value): bool => $value === 10;
+ $matchThree = static fn(int $value): bool => $value === 3;
+
+ $actual = $collection->findBy($matchTen, $matchThree);
+
+ /** @Then it should return the first element matching any predicate */
+ self::assertSame(3, $actual);
+ }
+
+ public function testFindReturnsNullWithoutPredicates(): void
+ {
+ /** @Given an eager collection with truthy and falsy values */
+ $collection = Collection::createFrom(elements: [0, 1, 2]);
+
+ /** @When finding without predicates */
+ $actual = $collection->findBy();
+
+ /** @Then it should return null */
+ self::assertNull($actual);
+ }
+
+ public function testFindReturnsNullWhenNoMatch(): void
+ {
+ /** @Given an eager collection of integers */
+ $collection = Collection::createFrom(elements: [1, 2, 3]);
+
+ /** @When finding an element greater than 100 */
+ $actual = $collection->findBy(predicates: static fn(int $value): bool => $value > 100);
+
+ /** @Then it should return null */
+ self::assertNull($actual);
+ }
+
+ public function testEach(): void
+ {
+ /** @Given an eager collection of integers */
+ $collection = Collection::createFrom(elements: [1, 2, 3]);
+
+ /** @And a variable to accumulate a sum */
+ $sum = 0;
+
+ /** @When using each to accumulate the sum */
+ $actual = $collection->each(actions: function (int $value) use (&$sum): void {
+ $sum += $value;
+ });
+
+ /** @Then the sum should be 6 */
+ self::assertSame(6, $sum);
+
+ /** @And the returned collection should be the same instance */
+ self::assertSame($collection, $actual);
+ }
+
+ public function testEqualsWithIdenticalCollections(): void
+ {
+ /** @Given a first eager collection */
+ $first = Collection::createFrom(elements: [1, 2, 3]);
+
+ /** @And a second eager collection with the same elements */
+ $second = Collection::createFrom(elements: [1, 2, 3]);
+
+ /** @When comparing them for equality */
+ $actual = $first->equals(other: $second);
+
+ /** @Then they should be equal */
+ self::assertTrue($actual);
+ }
+
+ public function testEqualsWithDifferentCollections(): void
+ {
+ /** @Given a first eager collection */
+ $first = Collection::createFrom(elements: [1, 2, 3]);
+
+ /** @And a second eager collection with different elements */
+ $second = Collection::createFrom(elements: [1, 2, 4]);
+
+ /** @When comparing them for equality */
+ $actual = $first->equals(other: $second);
+
+ /** @Then they should not be equal */
+ self::assertFalse($actual);
+ }
+
+ public function testEqualsWithDifferentSizes(): void
+ {
+ /** @Given a first eager collection with three elements */
+ $first = Collection::createFrom(elements: [1, 2, 3]);
+
+ /** @And a second eager collection with two elements */
+ $second = Collection::createFrom(elements: [1, 2]);
+
+ /** @When comparing first equals second */
+ $firstEqualsSecond = $first->equals(other: $second);
+
+ /** @And comparing second equals first */
+ $secondEqualsFirst = $second->equals(other: $first);
+
+ /** @Then the first comparison should return false */
+ self::assertFalse($firstEqualsSecond);
+
+ /** @And the second comparison should return false */
+ self::assertFalse($secondEqualsFirst);
+ }
+
+ public function testEqualsWithDifferentSizesButSamePrefix(): void
+ {
+ /** @Given a first eager collection with four elements */
+ $first = Collection::createFrom(elements: [1, 2, 3, 4]);
+
+ /** @And a second eager collection with three elements */
+ $second = Collection::createFrom(elements: [1, 2, 3]);
+
+ /** @When comparing them for equality */
+ $actual = $first->equals(other: $second);
+
+ /** @Then they should not be equal */
+ self::assertFalse($actual);
+ }
+
+ public function testEqualsWithNullElementsAndDifferentSizes(): void
+ {
+ /** @Given a first eager collection with three elements */
+ $first = Collection::createFrom(elements: [1, 2, 3]);
+
+ /** @And a second eager collection with four elements ending with null */
+ $second = Collection::createFrom(elements: [1, 2, 3, null]);
+
+ /** @When comparing them for equality */
+ $actual = $first->equals(other: $second);
+
+ /** @Then they should not be equal */
+ self::assertFalse($actual);
+ }
+
+ public function testRemoveElement(): void
+ {
+ /** @Given an eager collection with duplicate elements */
+ $collection = Collection::createFrom(elements: [1, 2, 3, 2, 4]);
+
+ /** @When removing the value 2 */
+ $actual = $collection->remove(element: 2);
+
+ /** @Then all occurrences of 2 should be removed */
+ self::assertSame([1, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testRemoveScalarFromObjectCollection(): void
+ {
+ /** @Given an eager collection with Amount objects */
+ $collection = Collection::createFrom(elements: [
+ new Amount(value: 50.00, currency: Currency::USD),
+ new Amount(value: 100.00, currency: Currency::USD)
+ ]);
+
+ /** @When removing a scalar value */
+ $actual = $collection->remove(element: 50.00);
+
+ /** @Then no elements should be removed */
+ self::assertSame(2, $actual->count());
+ }
+
+ public function testRemovePreservesKeys(): void
+ {
+ /** @Given an eager collection with string keys */
+ $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]);
+
+ /** @When removing the value 2 */
+ $actual = $collection->remove(element: 2);
+
+ /** @Then the remaining keys should be preserved */
+ self::assertSame(['a' => 1, 'c' => 3], $actual->toArray());
+ }
+
+ public function testRemoveAllWithPredicate(): void
+ {
+ /** @Given an eager collection of integers */
+ $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5]);
+
+ /** @When removing all elements greater than 3 */
+ $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 3);
+
+ /** @Then only elements 1, 2, 3 should remain */
+ self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testRemoveAllWithoutPredicate(): void
+ {
+ /** @Given an eager collection of integers */
+ $collection = Collection::createFrom(elements: [1, 2, 3]);
+
+ /** @When removing all without a predicate */
+ $actual = $collection->removeAll();
+
+ /** @Then the collection should be empty */
+ self::assertSame(0, $actual->count());
+ }
+
+ public function testRemoveAllWithNonMatchingFirstElement(): void
+ {
+ /** @Given an eager collection where the first element does not match the predicate */
+ $collection = Collection::createFrom(elements: [1, 10, 2, 20, 3]);
+
+ /** @When removing all elements greater than 5 */
+ $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 5);
+
+ /** @Then elements 1, 2, 3 should remain */
+ self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testRemoveAllPreservesKeys(): void
+ {
+ /** @Given an eager collection with string keys */
+ $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]);
+
+ /** @When removing elements greater than 2 */
+ $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 2);
+
+ /** @Then the remaining keys should be preserved */
+ self::assertSame(['a' => 1, 'b' => 2], $actual->toArray());
+ }
+
+ public function testFirstReturnsElement(): void
+ {
+ /** @Given an eager collection with three elements */
+ $collection = Collection::createFrom(elements: [10, 20, 30]);
+
+ /** @When retrieving the first element */
+ $actual = $collection->first();
+
+ /** @Then it should return 10 */
+ self::assertSame(10, $actual);
+ }
+
+ public function testFirstReturnsDefaultWhenEmpty(): void
+ {
+ /** @Given an empty eager collection */
+ $collection = Collection::createFromEmpty();
+
+ /** @When retrieving the first element with a default */
+ $actual = $collection->first(defaultValueIfNotFound: 'fallback');
+
+ /** @Then it should return the default value */
+ self::assertSame('fallback', $actual);
+ }
+
+ public function testFirstReturnsNullWhenEmpty(): void
+ {
+ /** @Given an empty eager collection */
+ $collection = Collection::createFromEmpty();
+
+ /** @When retrieving the first element without a default */
+ $actual = $collection->first();
+
+ /** @Then it should return null */
+ self::assertNull($actual);
+ }
+
+ public function testFirstReturnsNullElementInsteadOfDefault(): void
+ {
+ /** @Given an eager collection where the first element is null */
+ $collection = Collection::createFrom(elements: [null, 1, 2]);
+
+ /** @When retrieving the first element with a default */
+ $actual = $collection->first(defaultValueIfNotFound: 'fallback');
+
+ /** @Then it should return null, not the default */
+ self::assertNull($actual);
+ }
+
+ public function testFlatten(): void
+ {
+ /** @Given an eager collection with nested arrays */
+ $collection = Collection::createFrom(elements: [[1, 2], [3, 4], 5]);
+
+ /** @When flattening by one level */
+ $actual = $collection->flatten();
+
+ /** @Then all elements should be at the top level */
+ self::assertSame([1, 2, 3, 4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testGetByIndex(): void
+ {
+ /** @Given an eager collection with three elements */
+ $collection = Collection::createFrom(elements: ['a', 'b', 'c']);
+
+ /** @When retrieving the element at index 1 */
+ $actual = $collection->getBy(index: 1);
+
+ /** @Then it should return 'b' */
+ self::assertSame('b', $actual);
+ }
+
+ public function testGetByIndexReturnsDefaultWhenOutOfBounds(): void
+ {
+ /** @Given an eager collection with three elements */
+ $collection = Collection::createFrom(elements: ['a', 'b', 'c']);
+
+ /** @When retrieving an element at an index that does not exist */
+ $actual = $collection->getBy(index: 99, defaultValueIfNotFound: 'missing');
+
+ /** @Then it should return the default value */
+ self::assertSame('missing', $actual);
+ }
+
+ public function testGroupBy(): void
+ {
+ /** @Given an eager collection of integers from 1 to 6 */
+ $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5, 6]);
+
+ /** @When grouping by even and odd */
+ $actual = $collection->groupBy(
+ classifier: static fn(int $value): string => $value % 2 === 0 ? 'even' : 'odd'
+ );
+
+ /** @Then the odd group should contain 1, 3, 5 */
+ $groups = $actual->toArray();
+ self::assertSame([1, 3, 5], $groups['odd']);
+
+ /** @And the even group should contain 2, 4, 6 */
+ self::assertSame([2, 4, 6], $groups['even']);
+ }
+
+ public function testIsEmpty(): void
+ {
+ /** @Given an empty eager collection */
+ $empty = Collection::createFromEmpty();
+
+ /** @Then the empty collection should return true */
+ self::assertTrue($empty->isEmpty());
+ }
+
+ public function testIsNotEmpty(): void
+ {
+ /** @Given a non-empty eager collection */
+ $nonEmpty = Collection::createFrom(elements: [1]);
+
+ /** @Then the non-empty collection should return false */
+ self::assertFalse($nonEmpty->isEmpty());
+ }
+
+ public function testJoinToString(): void
+ {
+ /** @Given an eager collection of strings */
+ $collection = Collection::createFrom(elements: ['a', 'b', 'c']);
+
+ /** @When joining with a comma separator */
+ $actual = $collection->joinToString(separator: ', ');
+
+ /** @Then the result should be "a, b, c" */
+ self::assertSame('a, b, c', $actual);
+ }
+
+ public function testJoinToStringWithIntegers(): void
+ {
+ /** @Given an eager collection of integers */
+ $collection = Collection::createFrom(elements: [1, 2, 3]);
+
+ /** @When joining with a comma separator */
+ $actual = $collection->joinToString(separator: ', ');
+
+ /** @Then the result should be "1, 2, 3" */
+ self::assertSame('1, 2, 3', $actual);
+ }
+
+ public function testJoinToStringWithSingleInteger(): void
+ {
+ /** @Given an eager collection with a single integer */
+ $collection = Collection::createFrom(elements: [42]);
+
+ /** @When joining to string */
+ $actual = $collection->joinToString(separator: ', ');
+
+ /** @Then the result should be a string */
+ self::assertSame('42', $actual);
+ }
+
+ public function testFilterWithPredicate(): void
+ {
+ /** @Given an eager collection of integers */
+ $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5]);
+
+ /** @When keeping only elements greater than 3 */
+ $actual = $collection->filter(predicates: static fn(int $value): bool => $value > 3);
+
+ /** @Then only 4 and 5 should remain */
+ self::assertSame([4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testFilterWithoutPredicateRemovesFalsyValues(): void
+ {
+ /** @Given an eager collection with falsy and truthy values */
+ $collection = Collection::createFrom(elements: [0, '', null, false, 1, 'hello', 2]);
+
+ /** @When filtering without a predicate */
+ $actual = $collection->filter();
+
+ /** @Then only truthy values should remain */
+ self::assertSame([1, 'hello', 2], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testFilterWithExplicitNull(): void
+ {
+ /** @Given an eager collection with falsy and truthy values */
+ $collection = Collection::createFrom(elements: [0, '', 1, 'hello', 2]);
+
+ /** @When filtering with an explicit null predicate */
+ $actual = $collection->filter(null);
+
+ /** @Then only truthy values should remain */
+ self::assertSame([1, 'hello', 2], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testFilterPreservesKeys(): void
+ {
+ /** @Given an eager collection with string keys */
+ $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]);
+
+ /** @When filtering only elements greater than 1 */
+ $actual = $collection->filter(predicates: static fn(int $value): bool => $value > 1);
+
+ /** @Then the remaining keys should be preserved */
+ self::assertSame(['b' => 2, 'c' => 3], $actual->toArray());
+ }
+
+ public function testLastReturnsElement(): void
+ {
+ /** @Given an eager collection with three elements */
+ $collection = Collection::createFrom(elements: [10, 20, 30]);
+
+ /** @When retrieving the last element */
+ $actual = $collection->last();
+
+ /** @Then it should return 30 */
+ self::assertSame(30, $actual);
+ }
+
+ public function testLastReturnsDefaultWhenEmpty(): void
+ {
+ /** @Given an empty eager collection */
+ $collection = Collection::createFromEmpty();
+
+ /** @When retrieving the last element with a default */
+ $actual = $collection->last(defaultValueIfNotFound: 'fallback');
+
+ /** @Then it should return the default value */
+ self::assertSame('fallback', $actual);
+ }
+
+ public function testLastReturnsNullElementInsteadOfDefault(): void
+ {
+ /** @Given an eager collection where the last element is null */
+ $collection = Collection::createFrom(elements: [1, 2, null]);
+
+ /** @When retrieving the last element with a default */
+ $actual = $collection->last(defaultValueIfNotFound: 'fallback');
+
+ /** @Then it should return null, not the default */
+ self::assertNull($actual);
+ }
+
+ public function testMap(): void
+ {
+ /** @Given an eager collection of integers */
+ $collection = Collection::createFrom(elements: [1, 2, 3]);
+
+ /** @When transforming each element by multiplying by 10 */
+ $actual = $collection->map(transformations: static fn(int $value): int => $value * 10);
+
+ /** @Then each element should be multiplied */
+ self::assertSame([10, 20, 30], $actual->toArray());
+ }
+
+ public function testMapPreservesKeys(): void
+ {
+ /** @Given an eager collection with string keys */
+ $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]);
+
+ /** @When transforming each element */
+ $actual = $collection->map(transformations: static fn(int $value): int => $value * 10);
+
+ /** @Then the keys should be preserved */
+ self::assertSame(['a' => 10, 'b' => 20, 'c' => 30], $actual->toArray());
+ }
+
+ public function testReduce(): void
+ {
+ /** @Given an eager collection of integers */
+ $collection = Collection::createFrom(elements: [1, 2, 3, 4]);
+
+ /** @When reducing to calculate the sum */
+ $actual = $collection->reduce(
+ accumulator: static fn(int $carry, int $value): int => $carry + $value,
+ initial: 0
+ );
+
+ /** @Then the sum should be 10 */
+ self::assertSame(10, $actual);
+ }
+
+ public function testSortAscending(): void
+ {
+ /** @Given an eager collection with unordered elements */
+ $collection = Collection::createFrom(elements: [3, 1, 2]);
+
+ /** @When sorting in ascending order by value */
+ $actual = $collection->sort(order: Order::ASCENDING_VALUE);
+
+ /** @Then the elements should be in ascending order */
+ self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testSortDescending(): void
+ {
+ /** @Given an eager collection with ordered elements */
+ $collection = Collection::createFrom(elements: [1, 2, 3]);
+
+ /** @When sorting in descending order by value */
+ $actual = $collection->sort(order: Order::DESCENDING_VALUE);
+
+ /** @Then the elements should be in descending order */
+ self::assertSame([3, 2, 1], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testSortAscendingKey(): void
+ {
+ /** @Given an eager collection with unordered string keys */
+ $collection = Collection::createFrom(elements: ['c' => 3, 'a' => 1, 'b' => 2]);
+
+ /** @When sorting by ascending key */
+ $actual = $collection->sort();
+
+ /** @Then the keys should be in ascending order */
+ self::assertSame(['a' => 1, 'b' => 2, 'c' => 3], $actual->toArray());
+ }
+
+ public function testSortDescendingKey(): void
+ {
+ /** @Given an eager collection with ordered string keys */
+ $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]);
+
+ /** @When sorting by descending key */
+ $actual = $collection->sort(order: Order::DESCENDING_KEY);
+
+ /** @Then the keys should be in descending order */
+ self::assertSame(['c' => 3, 'b' => 2, 'a' => 1], $actual->toArray());
+ }
+
+ public function testSortAscendingValueWithoutComparator(): void
+ {
+ /** @Given an eager collection with unordered integers */
+ $collection = Collection::createFrom(elements: [3, 1, 4, 1, 5]);
+
+ /** @When sorting ascending by value without a custom comparator */
+ $actual = $collection->sort(order: Order::ASCENDING_VALUE);
+
+ /** @Then the elements should be sorted by the default spaceship operator */
+ self::assertSame([1, 1, 3, 4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testSortWithCustomComparator(): void
+ {
+ /** @Given an eager collection of Amount objects */
+ $collection = Collection::createFrom(elements: [
+ new Amount(value: 300.00, currency: Currency::USD),
+ new Amount(value: 100.00, currency: Currency::USD),
+ new Amount(value: 200.00, currency: Currency::USD)
+ ]);
+
+ /** @When sorting ascending by value with a custom comparator */
+ $actual = $collection->sort(
+ order: Order::ASCENDING_VALUE,
+ comparator: static fn(Amount $first, Amount $second): int => $first->value <=> $second->value
+ );
+
+ /** @Then the first element should have the lowest value */
+ self::assertSame(100.00, $actual->first()->value);
+
+ /** @And the last element should have the highest value */
+ self::assertSame(300.00, $actual->last()->value);
+ }
+
+ public function testSortWithCustomComparatorProducesDifferentOrderThanDefault(): void
+ {
+ /** @Given an eager collection where alphabetical and length order diverge */
+ $collection = Collection::createFrom(elements: ['zz', 'a', 'bbb']);
+
+ /** @When sorting ascending by length */
+ $byLength = $collection->sort(
+ order: Order::ASCENDING_VALUE,
+ comparator: static fn(string $first, string $second): int => strlen($first) <=> strlen($second)
+ );
+
+ /** @And sorting ascending by default (alphabetical) */
+ $byDefault = $collection->sort(order: Order::ASCENDING_VALUE);
+
+ /** @Then the custom order should be by length */
+ self::assertSame(['a', 'zz', 'bbb'], $byLength->toArray(keyPreservation: KeyPreservation::DISCARD));
+
+ /** @And the default order should be alphabetical */
+ self::assertSame(['a', 'bbb', 'zz'], $byDefault->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testSlice(): void
+ {
+ /** @Given an eager collection of five elements */
+ $collection = Collection::createFrom(elements: [10, 20, 30, 40, 50]);
+
+ /** @When slicing from offset 1 with length 2 */
+ $actual = $collection->slice(offset: 1, length: 2);
+
+ /** @Then the result should contain elements 20 and 30 */
+ self::assertSame([20, 30], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testSliceUntilEnd(): void
+ {
+ /** @Given an eager collection of five elements */
+ $collection = Collection::createFrom(elements: [10, 20, 30, 40, 50]);
+
+ /** @When slicing from offset 2 without specifying length */
+ $actual = $collection->slice(offset: 2);
+
+ /** @Then the result should contain all elements from index 2 onward */
+ self::assertSame([30, 40, 50], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testSlicePreservesKeys(): void
+ {
+ /** @Given an eager collection with string keys */
+ $collection = Collection::createFrom(elements: ['a' => 10, 'b' => 20, 'c' => 30, 'd' => 40]);
+
+ /** @When slicing from offset 1 with length 2 */
+ $actual = $collection->slice(offset: 1, length: 2);
+
+ /** @Then the keys should be preserved */
+ self::assertSame(['b' => 20, 'c' => 30], $actual->toArray());
+ }
+
+ public function testSliceWithZeroLengthReturnsEmpty(): void
+ {
+ /** @Given an eager collection with five elements */
+ $collection = Collection::createFrom(elements: [10, 20, 30, 40, 50]);
+
+ /** @When slicing with length zero */
+ $actual = $collection->slice(offset: 0, length: 0);
+
+ /** @Then the result should be empty */
+ self::assertTrue($actual->isEmpty());
+
+ /** @And the count should be zero */
+ self::assertSame(0, $actual->count());
+ }
+
+ public function testSliceWithNegativeLengthExcludesTrailingElements(): void
+ {
+ /** @Given an eager collection with five elements */
+ $collection = Collection::createFrom(elements: [10, 20, 30, 40, 50]);
+
+ /** @When slicing from offset 0 with length -2 (exclude last 2) */
+ $actual = $collection->slice(offset: 0, length: -2);
+
+ /** @Then the result should contain the first three elements */
+ self::assertSame([10, 20, 30], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testSliceWithOffsetAndNegativeLength(): void
+ {
+ /** @Given an eager collection with five elements */
+ $collection = Collection::createFrom(elements: [10, 20, 30, 40, 50]);
+
+ /** @When slicing from offset 1 with length -2 (skip first, exclude last 2) */
+ $actual = $collection->slice(offset: 1, length: -2);
+
+ /** @Then the result should contain elements 20 and 30 */
+ self::assertSame([20, 30], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testSliceWithNegativeLengthPreservesKeys(): void
+ {
+ /** @Given an eager collection with string keys */
+ $collection = Collection::createFrom(elements: ['a' => 10, 'b' => 20, 'c' => 30, 'd' => 40]);
+
+ /** @When slicing from offset 0 with length -2 */
+ $actual = $collection->slice(offset: 0, length: -2);
+
+ /** @Then the keys should be preserved */
+ self::assertSame(['a' => 10, 'b' => 20], $actual->toArray());
+ }
+
+ public function testSliceWithNegativeLengthProducesExactCount(): void
+ {
+ /** @Given an eager collection with six elements */
+ $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5, 6]);
+
+ /** @When slicing from offset 0 with length -3 (exclude last 3) */
+ $actual = $collection->slice(offset: 0, length: -3);
+
+ /** @Then the collection should contain exactly 3 elements */
+ self::assertCount(3, $actual);
+
+ /** @And the elements should be 1, 2, 3 */
+ self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testToArrayPreservingKeys(): void
+ {
+ /** @Given an eager collection with non-sequential keys */
+ $collection = Collection::createFrom(elements: [0 => 'a', 2 => 'b', 5 => 'c']);
+
+ /** @When converting to array preserving keys */
+ $actual = $collection->toArray();
+
+ /** @Then the keys should be preserved */
+ self::assertSame([0 => 'a', 2 => 'b', 5 => 'c'], $actual);
+ }
+
+ public function testToArrayDiscardingKeys(): void
+ {
+ /** @Given an eager collection with non-sequential keys */
+ $collection = Collection::createFrom(elements: [0 => 'a', 2 => 'b', 5 => 'c']);
+
+ /** @When converting to array discarding keys */
+ $actual = $collection->toArray(keyPreservation: KeyPreservation::DISCARD);
+
+ /** @Then the keys should be re-indexed from 0 */
+ self::assertSame(['a', 'b', 'c'], $actual);
+ }
+
+ public function testToJson(): void
+ {
+ /** @Given an eager collection of integers */
+ $collection = Collection::createFrom(elements: [1, 2, 3]);
+
+ /** @When converting to JSON */
+ $actual = $collection->toJson();
+
+ /** @Then the result should be a valid JSON array */
+ self::assertSame('[1,2,3]', $actual);
+ }
+
+ public function testToJsonDiscardingKeys(): void
+ {
+ /** @Given an eager collection with string keys */
+ $collection = Collection::createFrom(elements: ['x' => 1, 'y' => 2]);
+
+ /** @When converting to JSON discarding keys */
+ $actual = $collection->toJson(keyPreservation: KeyPreservation::DISCARD);
+
+ /** @Then the result should be a sequential JSON array */
+ self::assertSame('[1,2]', $actual);
+ }
+
+ public function testToJsonPreservesKeysByDefault(): void
+ {
+ /** @Given an eager collection with string keys */
+ $collection = Collection::createFrom(elements: ['x' => 1, 'y' => 2]);
+
+ /** @When converting to JSON without arguments */
+ $actual = $collection->toJson();
+
+ /** @Then the result should preserve keys as a JSON object */
+ self::assertSame('{"x":1,"y":2}', $actual);
+ }
+
+ public function testImmutability(): void
+ {
+ /** @Given an eager collection with three elements */
+ $original = Collection::createFrom(elements: [1, 2, 3]);
+
+ /** @When adding a new element */
+ $modified = $original->add(4);
+
+ /** @Then the original collection should remain unchanged */
+ self::assertSame(3, $original->count());
+
+ /** @And the new collection should have four elements */
+ self::assertSame(4, $modified->count());
+ }
+
+ public function testChainedOperationsWithObjects(): void
+ {
+ /** @Given an eager collection of Amount objects */
+ $collection = Collection::createFrom(elements: [
+ new Amount(value: 50.00, currency: Currency::USD),
+ new Amount(value: 100.00, currency: Currency::USD),
+ new Amount(value: 150.00, currency: Currency::USD),
+ new Amount(value: 250.00, currency: Currency::USD),
+ new Amount(value: 500.00, currency: Currency::USD)
+ ]);
+
+ /** @And a variable to accumulate the total discounted value */
+ $totalDiscounted = 0.0;
+
+ /** @When chaining filter, map, removeAll, sort and each */
+ $actual = $collection
+ ->filter(predicates: static fn(Amount $amount): bool => $amount->value >= 100)
+ ->map(transformations: static fn(Amount $amount): Amount => new Amount(
+ value: $amount->value * 0.9,
+ currency: $amount->currency
+ ))
+ ->removeAll(predicate: static fn(Amount $amount): bool => $amount->value > 300)
+ ->sort(
+ order: Order::ASCENDING_VALUE,
+ comparator: static fn(Amount $first, Amount $second): int => $first->value <=> $second->value
+ )
+ ->each(actions: function (Amount $amount) use (&$totalDiscounted): void {
+ $totalDiscounted += $amount->value;
+ });
+
+ /** @Then the final collection should contain exactly three elements */
+ self::assertCount(3, $actual);
+
+ /** @And the total discounted value should be 450 */
+ self::assertSame(450.00, $totalDiscounted);
+
+ /** @And the first Amount should be 90 after the discount */
+ self::assertSame(90.00, $actual->first()->value);
+
+ /** @And the last Amount should be 225 after the discount */
+ self::assertSame(225.00, $actual->last()->value);
+ }
+
+ public function testChainedOperationsWithIntegers(): void
+ {
+ /** @Given an eager collection of integers from 1 to 100 */
+ $collection = Collection::createFrom(elements: range(1, 100));
+
+ /** @When keeping even numbers, squaring them, and sorting in descending order */
+ $actual = $collection
+ ->filter(predicates: static fn(int $value): bool => $value % 2 === 0)
+ ->map(transformations: static fn(int $value): int => $value ** 2)
+ ->sort(order: Order::DESCENDING_VALUE);
+
+ /** @Then the first element should be 10000 (square of 100) */
+ self::assertSame(10000, $actual->first());
+
+ /** @And the last element should be 4 (square of 2) */
+ self::assertSame(4, $actual->last());
+
+ /** @When reducing to calculate the sum of all squared even numbers */
+ $sum = $actual->reduce(
+ accumulator: static fn(int $carry, int $value): int => $carry + $value,
+ initial: 0
+ );
+
+ /** @Then the sum should be 171700 */
+ self::assertSame(171700, $sum);
+ }
+}
diff --git a/tests/Internal/Iterators/LazyIteratorTest.php b/tests/Internal/Iterators/LazyIteratorTest.php
deleted file mode 100644
index 15530ce..0000000
--- a/tests/Internal/Iterators/LazyIteratorTest.php
+++ /dev/null
@@ -1,34 +0,0 @@
- $value) {
- yield $key => $value * 2;
- }
- }
- };
-
- /** @When creating a LazyIterator from the elements and operation */
- $iterator = LazyIterator::from(elements: $elements, operation: $operation);
-
- /** @Then the yielded elements should include the operation effect */
- self::assertSame([2, 4, 6], iterator_to_array($iterator));
- }
-}
diff --git a/tests/Internal/Operations/Aggregate/CollectionReduceOperationTest.php b/tests/Internal/Operations/Aggregate/CollectionReduceOperationTest.php
deleted file mode 100644
index f4c1cbe..0000000
--- a/tests/Internal/Operations/Aggregate/CollectionReduceOperationTest.php
+++ /dev/null
@@ -1,126 +0,0 @@
-sumByCustomer(customer: 'Customer A');
-
- /** @Then the total amount for 'Customer A' should be 250.5 */
- self::assertSame(250.5, $actual);
- }
-
- public function testReduceSumOfNumbers(): void
- {
- /** @Given a collection of numbers */
- $numbers = InvoiceSummaries::createFrom(elements: [1, 2, 3, 4, 5]);
-
- /** @When reducing the collection to a sum */
- $actual = $numbers->reduce(
- aggregator: static fn(int $carry, int $number): int => $carry + $number,
- initial: 0
- );
-
- /** @Then the sum should be correct */
- self::assertSame(15, $actual);
- }
-
- public function testReduceProductOfNumbers(): void
- {
- /** @Given a collection of numbers */
- $numbers = InvoiceSummaries::createFrom(elements: [1, 2, 3, 4]);
-
- /** @When reducing the collection to a product */
- $actual = $numbers->reduce(
- aggregator: static fn(int $carry, int $number): int => $carry * $number,
- initial: 1
- );
-
- /** @Then the product should be correct */
- self::assertSame(24, $actual);
- }
-
- public function testReduceWhenNoMatchFound(): void
- {
- /** @Given a collection of invoice summaries */
- $summaries = InvoiceSummaries::createFrom(elements: [
- new InvoiceSummary(amount: 100.0, customer: 'Customer A'),
- new InvoiceSummary(amount: 150.5, customer: 'Customer A'),
- new InvoiceSummary(amount: 200.75, customer: 'Customer B')
- ]);
-
- /** @When reducing the collection for a customer with no match */
- $actual = $summaries
- ->filter(predicates: static fn(InvoiceSummary $summary): bool => $summary->customer === 'Customer C')
- ->reduce(aggregator: static fn(float $carry, InvoiceSummary $summary): float => $carry + $summary->amount,
- initial: 0.0);
-
- /** @Then the total amount for 'Customer C' should be zero */
- self::assertSame(0.0, $actual);
- }
-
- public function testReduceWithMixedDataTypes(): void
- {
- /** @Given a collection with mixed data types */
- $mixedData = InvoiceSummaries::createFrom(elements: [1, 'string', 3.14, true]);
-
- /** @When reducing the collection by concatenating values as strings */
- $actual = $mixedData->reduce(
- aggregator: static fn(string $carry, mixed $value): string => $carry . $value,
- initial: ''
- );
-
- /** @Then the concatenated string should be correct */
- self::assertSame('1string3.141', $actual);
- }
-
- public function testReduceSumForEmptyCollection(): void
- {
- /** @Given an empty collection of invoice summaries */
- $summaries = InvoiceSummaries::createFrom(elements: []);
-
- /** @When reducing an empty collection */
- $actual = $summaries->reduce(
- aggregator: static fn(float $carry, InvoiceSummary $summary): float => $carry + $summary->amount,
- initial: 0.0
- );
-
- /** @Then the total amount should be zero */
- self::assertSame(0.0, $actual);
- }
-
- public function testReduceWithDifferentInitialValue(): void
- {
- /** @Given a collection of invoice summaries */
- $summaries = InvoiceSummaries::createFrom(elements: [
- new InvoiceSummary(amount: 100.0, customer: 'Customer A'),
- new InvoiceSummary(amount: 150.5, customer: 'Customer A'),
- new InvoiceSummary(amount: 200.75, customer: 'Customer B')
- ]);
-
- /** @When summing the amounts for customer 'Customer A' with an initial value of 50 */
- $actual = $summaries
- ->filter(predicates: static fn(InvoiceSummary $summary): bool => $summary->customer === 'Customer A')
- ->reduce(aggregator: static fn(float $carry, InvoiceSummary $summary): float => $carry + $summary->amount,
- initial: 50.0);
-
- /** @Then the total amount for 'Customer A' should be 300.5 */
- self::assertSame(300.5, $actual);
- }
-}
diff --git a/tests/Internal/Operations/Compare/CollectionContainsOperationTest.php b/tests/Internal/Operations/Compare/CollectionContainsOperationTest.php
deleted file mode 100644
index 38cfd97..0000000
--- a/tests/Internal/Operations/Compare/CollectionContainsOperationTest.php
+++ /dev/null
@@ -1,87 +0,0 @@
-contains(element: $element);
-
- /** @Then the collection should contain the element */
- self::assertTrue($actual);
- }
-
- #[DataProvider('doesNotContainElementDataProvider')]
- public function testDoesNotContainElement(iterable $elements, mixed $element): void
- {
- /** @Given a collection */
- $collection = Collection::createFrom(elements: $elements);
-
- /** @When checking if the element is contained in the collection */
- $actual = $collection->contains(element: $element);
-
- /** @Then the collection should not contain the element */
- self::assertFalse($actual);
- }
-
- public static function containsElementDataProvider(): iterable
- {
- yield 'Collection contains null' => [
- 'elements' => [1, null, 3],
- 'element' => null
- ];
-
- yield 'Collection contains element' => [
- 'elements' => [
- new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'),
- new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH')
- ],
- 'element' => new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC')
- ];
-
- yield 'Collection contains scalar value' => [
- 'elements' => [1, 'key' => 'value', 3.5],
- 'element' => 'value'
- ];
- }
-
- public static function doesNotContainElementDataProvider(): iterable
- {
- yield 'Empty collection' => [
- 'elements' => [],
- 'element' => 1
- ];
-
- yield 'Collection does not contain object' => [
- 'elements' => [new stdClass()],
- 'element' => new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC')
- ];
-
- yield 'Collection does not contain element' => [
- 'elements' => [
- new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'),
- new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH')
- ],
- 'element' => new CryptoCurrency(name: 'Ripple', price: 1.0, symbol: 'XRP')
- ];
-
- yield 'Collection does not contain scalar value' => [
- 'elements' => [1, 'key' => 'value', 3.5],
- 'element' => 42
- ];
- }
-}
diff --git a/tests/Internal/Operations/Compare/CollectionEqualsOperationTest.php b/tests/Internal/Operations/Compare/CollectionEqualsOperationTest.php
deleted file mode 100644
index 4627b26..0000000
--- a/tests/Internal/Operations/Compare/CollectionEqualsOperationTest.php
+++ /dev/null
@@ -1,103 +0,0 @@
-equals(other: $collectionB);
-
- /** @Then the collections should be equal */
- self::assertTrue($actual);
- }
-
- #[DataProvider('collectionsNotEqualDataProvider')]
- public function testCollectionsAreNotEqual(iterable $elementsA, iterable $elementsB): void
- {
- /** @Given two collections */
- $collectionA = Collection::createFrom(elements: $elementsA);
- $collectionB = Collection::createFrom(elements: $elementsB);
-
- /** @When comparing the collections */
- $actual = $collectionA->equals(other: $collectionB);
-
- /** @Then the collections should not be equal */
- self::assertFalse($actual);
- }
-
- public static function collectionsEqualDataProvider(): iterable
- {
- yield 'Empty collections are equal' => [
- 'elementsA' => [],
- 'elementsB' => []
- ];
-
- yield 'Collections are equal' => [
- 'elementsA' => [
- new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'),
- new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH')
- ],
- 'elementsB' => [
- new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'),
- new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH')
- ]
- ];
-
- yield 'Collections with mixed keys and values' => [
- 'elementsA' => [1, 'key' => 'value', 3.5],
- 'elementsB' => [1, 'key' => 'value', 3.5]
- ];
- }
-
- public static function collectionsNotEqualDataProvider(): iterable
- {
- yield 'Collections are not equal' => [
- 'elementsA' => [
- new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC')
- ],
- 'elementsB' => [
- new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH')
- ]
- ];
-
- yield 'Collections with different sizes' => [
- 'elementsA' => [],
- 'elementsB' => [1]
- ];
-
- yield 'Scalar and non-scalar comparison' => [
- 'elementsA' => [1],
- 'elementsB' => [new stdClass()]
- ];
-
- yield 'Collections with different null handling' => [
- 'elementsA' => [null],
- 'elementsB' => []
- ];
-
- yield 'Collections with different sizes and null' => [
- 'elementsA' => [1, 2],
- 'elementsB' => [1, 2, null]
- ];
-
- yield 'Same elements in different order are not equal' => [
- 'elementsA' => [1, 2, 3],
- 'elementsB' => [3, 2, 1]
- ];
- }
-}
diff --git a/tests/Internal/Operations/Filter/CollectionFilterOperationTest.php b/tests/Internal/Operations/Filter/CollectionFilterOperationTest.php
deleted file mode 100644
index 3eec601..0000000
--- a/tests/Internal/Operations/Filter/CollectionFilterOperationTest.php
+++ /dev/null
@@ -1,142 +0,0 @@
-filter(
- static fn(int $value): bool => $value > 2,
- static fn(int $value): bool => $value % 2 === 0,
- static fn(int $value): bool => $value < 6
- );
-
- /** @Then the filtered collection should discard the keys and return the expected values */
- self::assertSame([4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
- }
-
- public function testFilterFailsIfPredicatesAreNotReindex(): void
- {
- /** @Given a collection with elements */
- $collection = Collection::createFrom(elements: [10, 20, 30]);
-
- /** @When filtering with a null predicate in the first position. */
- $actual = $collection->filter(
- null,
- static fn(int $value): bool => $value > 15
- );
-
- /** @Then the filter should still work correctly. */
- $elements = iterator_to_array($actual);
-
- self::assertCount(2, $elements);
- self::assertContains(20, $elements);
- self::assertContains(30, $elements);
- }
-
- public function testFilterIgnoresNullPredicatesAndReindexesWeights(): void
- {
- /** @Given a collection with elements */
- $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5]);
-
- /** @When filtering using null values interspersed with valid predicates. */
- $actual = $collection->filter(
- null,
- static fn(int $value): bool => $value > 2,
- null,
- static fn(int $value): bool => $value < 5
- );
-
- /** @Then it should only apply the valid predicates (result should be 3 and 4) */
- self::assertSame([3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
- }
-
- #[DataProvider('elementsDataProvider')]
- public function testFilterAppliesDefaultArrayFilter(iterable $elements, iterable $expected): void
- {
- /** @Given a collection with elements */
- $collection = Collection::createFrom(elements: $elements);
-
- /** @When filtering the collection without any predicates (using default truthy filter) */
- $actual = $collection->filter();
-
- /** @Then the filtered collection should contain only truthy elements */
- self::assertSame(array_values((array)$expected), $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
- }
-
- #[DataProvider('elementsDataProviderWithKeys')]
- public function testFilterPreservesKeys(iterable $elements, iterable $expected): void
- {
- /** @Given a collection with elements and keys */
- $collection = Collection::createFrom(elements: $elements);
-
- /** @When filtering the collection without any predicates (using default truthy filter) */
- $actual = $collection->filter();
-
- /** @Then the filtered collection should preserve keys for truthy elements */
- self::assertSame($expected, $actual->toArray());
- }
-
- public static function elementsDataProvider(): iterable
- {
- $bitcoin = new CryptoCurrency(name: 'Bitcoin', price: (float)rand(60000, 999999), symbol: 'BTC');
-
- yield 'Empty array' => [
- 'elements' => [],
- 'expected' => []
- ];
-
- yield 'Array with boolean values' => [
- 'elements' => [false, true, false, true],
- 'expected' => [true, true]
- ];
-
- yield 'Array with null and numbers' => [
- 'elements' => [null, 1, 2, 0],
- 'expected' => [1, 2]
- ];
-
- yield 'Array with only falsy values' => [
- 'elements' => [0, '', null, false],
- 'expected' => []
- ];
-
- yield 'Array with objects and truthy values' => [
- 'elements' => [$bitcoin, 1, 'valid string'],
- 'expected' => [$bitcoin->toArray(keyPreservation: KeyPreservation::DISCARD), 1, 'valid string']
- ];
- }
-
- public static function elementsDataProviderWithKeys(): iterable
- {
- $bitcoin = new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC');
-
- yield 'Array with keys preserved' => [
- 'elements' => ['a' => false, 'b' => true, 'c' => false, 'd' => true],
- 'expected' => ['b' => true, 'd' => true]
- ];
-
- yield 'Mixed elements with keys' => [
- 'elements' => ['first' => null, 'second' => 1, 'third' => $bitcoin],
- 'expected' => ['second' => 1, 'third' => $bitcoin->toArray()]
- ];
- }
-}
diff --git a/tests/Internal/Operations/Order/CollectionSortOperationTest.php b/tests/Internal/Operations/Order/CollectionSortOperationTest.php
deleted file mode 100644
index 9b32126..0000000
--- a/tests/Internal/Operations/Order/CollectionSortOperationTest.php
+++ /dev/null
@@ -1,200 +0,0 @@
-sort();
-
- /** @Then the collection should remain empty */
- self::assertSame([], $actual->toArray());
- }
-
- #[DataProvider('ascendingKeySortDataProvider')]
- public function testSortAscendingByKey(iterable $elements, iterable $expected): void
- {
- /** @Given a collection with unordered elements */
- $collection = Collection::createFrom(elements: $elements);
-
- /** @When sorting the collection in ascending order by key */
- $actual = $collection->sort();
-
- /** @Then the collection should be sorted by key in ascending order */
- self::assertSame($expected, $actual->toArray());
- }
-
- #[DataProvider('descendingKeySortDataProvider')]
- public function testSortDescendingByKey(iterable $elements, iterable $expected): void
- {
- /** @Given a collection with unordered elements */
- $collection = Collection::createFrom(elements: $elements);
-
- /** @When sorting the collection in descending order by key */
- $actual = $collection->sort(order: Order::DESCENDING_KEY);
-
- /** @Then the collection should be sorted by key in descending order */
- self::assertSame($expected, $actual->toArray());
- }
-
- #[DataProvider('ascendingValueSortDataProvider')]
- public function testSortAscendingByValue(iterable $elements, iterable $expected): void
- {
- /** @Given a collection with unordered elements */
- $collection = Collection::createFrom(elements: $elements);
-
- /** @When sorting the collection in ascending order by value */
- $actual = $collection->sort(order: Order::ASCENDING_VALUE);
-
- /** @Then the collection should be sorted by value in ascending order */
- self::assertSame($expected, $actual->toArray());
- }
-
- #[DataProvider('descendingValueSortDataProvider')]
- public function testSortDescendingByValue(iterable $elements, iterable $expected): void
- {
- /** @Given a collection with unordered elements */
- $collection = Collection::createFrom(elements: $elements);
-
- /** @When sorting the collection in descending order by value */
- $actual = $collection->sort(order: Order::DESCENDING_VALUE);
-
- /** @Then the collection should be sorted by value in descending order */
- self::assertSame($expected, $actual->toArray());
- }
-
- public function testSortDescendingByValueWithPredicate(): void
- {
- /** @Given a collection with unordered Amount objects */
- $collection = Collection::createFrom(elements: [
- new Amount(value: 100.50, currency: Currency::USD),
- new Amount(value: 150.75, currency: Currency::EUR),
- new Amount(value: 200.00, currency: Currency::USD)
- ]);
-
- /** @When sorting the collection in descending order by value with a custom predicate */
- $actual = $collection->sort(
- order: Order::DESCENDING_VALUE,
- predicate: static fn(Amount $first, Amount $second): int => $first->value <=> $second->value
- );
-
- /** @Then the collection should be sorted by value in descending order */
- self::assertSame([
- 2 => ['value' => 200.00, 'currency' => Currency::USD->name],
- 1 => ['value' => 150.75, 'currency' => Currency::EUR->name],
- 0 => ['value' => 100.50, 'currency' => Currency::USD->name]
- ], $actual->toArray());
- }
-
- public static function ascendingKeySortDataProvider(): iterable
- {
- yield 'Floats ascending by key' => [
- 'elements' => ['3.1' => 'a', '1.1' => 'b', '4.1' => 'c', '5.1' => 'd', '2.1' => 'e'],
- 'expected' => ['1.1' => 'b', '2.1' => 'e', '3.1' => 'a', '4.1' => 'c', '5.1' => 'd']
- ];
-
- yield 'Strings ascending by key' => [
- 'elements' => ['c' => 'apple', 'a' => 'banana', 'b' => 'cherry'],
- 'expected' => ['a' => 'banana', 'b' => 'cherry', 'c' => 'apple']
- ];
-
- yield 'Integers ascending by key' => [
- 'elements' => [3 => 'a', 1 => 'b', 4 => 'c', 5 => 'd', 2 => 'e'],
- 'expected' => [1 => 'b', 2 => 'e', 3 => 'a', 4 => 'c', 5 => 'd']
- ];
- }
-
- public static function ascendingValueSortDataProvider(): iterable
- {
- yield 'Floats ascending by value' => [
- 'elements' => [3 => 5.5, 1 => 1.1, 4 => 3.3, 5 => 4.4, 2 => 2.2],
- 'expected' => [1 => 1.1, 2 => 2.2, 4 => 3.3, 5 => 4.4, 3 => 5.5]
- ];
-
- yield 'Objects ascending by value' => [
- 'elements' => [
- new Amount(value: 200.00, currency: Currency::USD),
- new Amount(value: 150.75, currency: Currency::EUR),
- new Amount(value: 100.50, currency: Currency::USD)
- ],
- 'expected' => [
- 2 => ['value' => 100.50, 'currency' => Currency::USD->name],
- 1 => ['value' => 150.75, 'currency' => Currency::EUR->name],
- 0 => ['value' => 200.00, 'currency' => Currency::USD->name]
- ]
- ];
-
- yield 'Strings ascending by value' => [
- 'elements' => [3 => 'c', 1 => 'a', 4 => 'd', 5 => 'b', 2 => 'e'],
- 'expected' => [1 => 'a', 5 => 'b', 3 => 'c', 4 => 'd', 2 => 'e']
- ];
-
- yield 'Integers ascending by value' => [
- 'elements' => [3 => 5, 1 => 1, 4 => 3, 5 => 4, 2 => 2],
- 'expected' => [1 => 1, 2 => 2, 4 => 3, 5 => 4, 3 => 5]
- ];
- }
-
- public static function descendingKeySortDataProvider(): iterable
- {
- yield 'Floats descending by key' => [
- 'elements' => ['3.1' => 'a', '1.1' => 'b', '4.1' => 'c', '5.1' => 'd', '2.1' => 'e'],
- 'expected' => ['5.1' => 'd', '4.1' => 'c', '3.1' => 'a', '2.1' => 'e', '1.1' => 'b']
- ];
-
- yield 'Strings descending by key' => [
- 'elements' => ['c' => 'apple', 'a' => 'banana', 'b' => 'cherry'],
- 'expected' => ['c' => 'apple', 'b' => 'cherry', 'a' => 'banana']
- ];
-
- yield 'Integers descending by key' => [
- 'elements' => [3 => 'a', 1 => 'b', 4 => 'c', 5 => 'd', 2 => 'e'],
- 'expected' => [5 => 'd', 4 => 'c', 3 => 'a', 2 => 'e', 1 => 'b']
- ];
- }
-
- public static function descendingValueSortDataProvider(): iterable
- {
- yield 'Floats descending by value' => [
- 'elements' => [3 => 5.5, 1 => 1.1, 4 => 3.3, 5 => 4.4, 2 => 2.2],
- 'expected' => [3 => 5.5, 5 => 4.4, 4 => 3.3, 2 => 2.2, 1 => 1.1]
- ];
-
- yield 'Objects descending by value' => [
- 'elements' => [
- new Amount(value: 100.50, currency: Currency::USD),
- new Amount(value: 150.75, currency: Currency::EUR),
- new Amount(value: 200.00, currency: Currency::USD)
- ],
- 'expected' => [
- 2 => ['value' => 200.00, 'currency' => Currency::USD->name],
- 1 => ['value' => 150.75, 'currency' => Currency::EUR->name],
- 0 => ['value' => 100.50, 'currency' => Currency::USD->name]
- ]
- ];
-
- yield 'Strings descending by value' => [
- 'elements' => [3 => 'c', 1 => 'a', 4 => 'd', 5 => 'b', 2 => 'e'],
- 'expected' => [2 => 'e', 4 => 'd', 3 => 'c', 5 => 'b', 1 => 'a']
- ];
-
- yield 'Integers descending by value' => [
- 'elements' => [3 => 5, 1 => 1, 4 => 3, 5 => 4, 2 => 2],
- 'expected' => [3 => 5, 5 => 4, 4 => 3, 2 => 2, 1 => 1]
- ];
- }
-}
diff --git a/tests/Internal/Operations/Retrieve/CollectionFindOperationTest.php b/tests/Internal/Operations/Retrieve/CollectionFindOperationTest.php
deleted file mode 100644
index bb0b6ba..0000000
--- a/tests/Internal/Operations/Retrieve/CollectionFindOperationTest.php
+++ /dev/null
@@ -1,98 +0,0 @@
-findBy(predicates: static fn(mixed $element): bool => true);
-
- /** @Then the result should be null */
- self::assertNull($actual);
- }
-
- public function testFindByReturnsNullWhenNoMatch(): void
- {
- /** @Given a collection with elements */
- $collection = Collection::createFrom(elements: [
- new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'),
- new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'),
- new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB')
- ]);
-
- /** @When attempting to find an element that doesn't match any condition */
- $actual = $collection->findBy(predicates: static fn(CryptoCurrency $element): bool => $element->symbol === 'XRP'
- );
-
- /** @Then the result should be null */
- self::assertNull($actual);
- }
-
- public function testFindByWithMultiplePredicates(): void
- {
- /** @Given a collection with elements */
- $elements = [
- new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'),
- new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'),
- new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB')
- ];
- $collection = Collection::createFrom(elements: $elements);
-
- /** @When attempting to find the first element matching multiple predicates */
- $actual = $collection->findBy(
- static fn(CryptoCurrency $element): bool => $element->symbol === 'BNB',
- static fn(CryptoCurrency $element): bool => $element->price < 2000.0
- );
-
- /** @Then the result should be the expected element */
- self::assertSame($elements[2], $actual);
- }
-
- public function testFindByReturnsFirstMatchingElement(): void
- {
- /** @Given a collection with elements */
- $elements = [
- new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'),
- new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'),
- new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB')
- ];
- $collection = Collection::createFrom(elements: $elements);
-
- /** @When attempting to find the first matching element */
- $actual = $collection->findBy(predicates: static fn(CryptoCurrency $element): bool => $element->symbol === 'ETH'
- );
-
- /** @Then the result should be the expected element */
- self::assertSame($elements[1], $actual);
- }
-
- public function testFindByWithMultiplePredicatesReturnsNullWhenNoMatch(): void
- {
- /** @Given a collection with elements */
- $collection = Collection::createFrom(elements: [
- new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'),
- new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'),
- new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB')
- ]);
-
- /** @When attempting to find an element matching multiple predicates that do not match */
- $actual = $collection->findBy(
- static fn(CryptoCurrency $element): bool => $element->symbol === 'XRP',
- static fn(CryptoCurrency $element): bool => $element->price < 1000.0
- );
-
- /** @Then the result should be null */
- self::assertNull($actual);
- }
-}
diff --git a/tests/Internal/Operations/Retrieve/CollectionFirstOperationTest.php b/tests/Internal/Operations/Retrieve/CollectionFirstOperationTest.php
deleted file mode 100644
index 12d0157..0000000
--- a/tests/Internal/Operations/Retrieve/CollectionFirstOperationTest.php
+++ /dev/null
@@ -1,65 +0,0 @@
-first();
-
- /** @Then the result should be the first CryptoCurrency object */
- self::assertSame($elements[0], $actual);
- }
-
- public function testFirstReturnsNullWhenFirstElementIsNull(): void
- {
- /** @Given a collection whose first element is null */
- $collection = Collection::createFrom(elements: [null, 'value']);
-
- /** @When retrieving the first element with a default value */
- $actual = $collection->first(defaultValueIfNotFound: 'default');
-
- /** @Then the first element should be null */
- self::assertNull($actual);
- }
-
- public function testFirstReturnsDefaultValueWhenCollectionIsEmpty(): void
- {
- /** @Given an empty collection */
- $collection = Collection::createFromEmpty();
-
- /** @When attempting to get the first element */
- $actual = $collection->first(defaultValueIfNotFound: 'default');
-
- /** @Then the result should be the default value */
- self::assertSame('default', $actual);
- }
-
- public function testFirstReturnsNullWhenCollectionIsEmptyWithoutDefaultValue(): void
- {
- /** @Given an empty collection */
- $collection = Collection::createFromEmpty();
-
- /** @When retrieving the first element without a default value */
- $actual = $collection->first();
-
- /** @Then the first element should be null */
- self::assertNull($actual);
- }
-}
diff --git a/tests/Internal/Operations/Retrieve/CollectionGetOperationTest.php b/tests/Internal/Operations/Retrieve/CollectionGetOperationTest.php
deleted file mode 100644
index 152b4bd..0000000
--- a/tests/Internal/Operations/Retrieve/CollectionGetOperationTest.php
+++ /dev/null
@@ -1,178 +0,0 @@
-getBy(index: 0);
-
- /** @Then the result should be the expected element */
- self::assertSame($elements[0], $actual);
- }
-
- public function testGetByIndexReturnsElementAtValidIndex(): void
- {
- /** @Given a collection with elements */
- $elements = [
- new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'),
- new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'),
- new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB')
- ];
- $collection = Collection::createFrom(elements: $elements);
-
- /** @When attempting to get an element at a valid index */
- $actual = $collection->getBy(index: 1);
-
- /** @Then the result should be the expected element */
- self::assertSame($elements[1], $actual);
- }
-
- public function testGetByIndexReturnsNullForNegativeIndex(): void
- {
- /** @Given a collection with elements */
- $collection = Collection::createFrom(elements: [
- new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'),
- new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'),
- new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB')
- ]);
-
- /** @When attempting to get an element at a negative index */
- $actual = $collection->getBy(index: -1);
-
- /** @Then the result should be null */
- self::assertNull($actual);
- }
-
- public function testGetByIndexReturnsDefaultValueWhenIndexIsNegative(): void
- {
- /** @Given a collection with elements */
- $collection = Collection::createFrom(elements: [
- new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'),
- new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'),
- new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB')
- ]);
-
- /** @And a default value when the element is not found */
- $defaultValue = 'not-found';
-
- /** @When attempting to get an element at a negative index */
- $actual = $collection->getBy(index: -1, defaultValueIfNotFound: $defaultValue);
-
- /** @Then the default value should be returned */
- self::assertSame($defaultValue, $actual);
- }
-
- public function testGetByIndexReturnsNullForEmptyCollection(): void
- {
- /** @Given an empty collection */
- $collection = Collection::createFrom(elements: []);
-
- /** @When attempting to get an element at any index */
- $actual = $collection->getBy(index: 0);
-
- /** @Then the result should be null */
- self::assertNull($actual);
- }
-
- public function testGetByIndexReturnsNullForOutOfRangeIndex(): void
- {
- /** @Given a collection with elements */
- $collection = Collection::createFrom(elements: [
- new CryptoCurrency(name: 'Bitcoin', price: (float)rand(60000, 999999), symbol: 'BTC'),
- new CryptoCurrency(name: 'Ethereum', price: (float)rand(10000, 60000), symbol: 'ETH'),
- new CryptoCurrency(name: 'Binance Coin', price: (float)rand(1000, 2000), symbol: 'BNB')
- ]);
-
- /** @When attempting to get an element at an out-of-range index */
- $actual = $collection->getBy(index: 10);
-
- /** @Then the result should be null */
- self::assertNull($actual);
- }
-
- public function testGetByIndexReturnsDefaultValueWhenElementExistsAndDefaultIsEqual(): void
- {
- /** @Given a collection with elements */
- $elements = [
- new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'),
- new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH')
- ];
- $collection = Collection::createFrom(elements: $elements);
-
- /** @When attempting to get an element at a valid index with a default return value equal to the element */
- $expected = $elements[1];
- $actual = $collection->getBy(index: 1, defaultValueIfNotFound: $expected);
-
- /** @Then the result should be the element at the given index */
- self::assertSame($expected, $actual);
- }
-
- #[DataProvider('getByIndexDataProvider')]
- public function testGetByIndexReturnsExpectedValue(int $index, iterable $elements, mixed $expected): void
- {
- /** @Given a collection with elements */
- $collection = Collection::createFrom(elements: $elements);
-
- /** @When attempting to get an element at the specified index */
- $actual = $collection->getBy(index: $index);
-
- /** @Then the result should be the expected value */
- self::assertEquals($expected, $actual);
- }
-
- public static function getByIndexDataProvider(): iterable
- {
- yield 'Valid index' => [
- 'index' => 1,
- 'elements' => [
- new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'),
- new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'),
- new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB')
- ],
- 'expected' => new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH')
- ];
-
- yield 'Negative index' => [
- 'index' => -1,
- 'elements' => [
- new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'),
- new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'),
- new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB')
- ],
- 'expected' => null
- ];
-
- yield 'Empty elements' => [
- 'index' => 0,
- 'elements' => [],
- 'expected' => null
- ];
-
- yield 'Out of range index' => [
- 'index' => 5,
- 'elements' => [
- new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'),
- new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH')
- ],
- 'expected' => null
- ];
- }
-}
diff --git a/tests/Internal/Operations/Retrieve/CollectionLastOperationTest.php b/tests/Internal/Operations/Retrieve/CollectionLastOperationTest.php
deleted file mode 100644
index c1d0fb8..0000000
--- a/tests/Internal/Operations/Retrieve/CollectionLastOperationTest.php
+++ /dev/null
@@ -1,95 +0,0 @@
-last();
-
- /** @Then the result should be the last CryptoCurrency object */
- self::assertSame($elements[2], $actual);
- }
-
- public function testLastReturnsNullWhenLastElementIsNull(): void
- {
- /** @Given a collection whose last element is null */
- $collection = Collection::createFrom(elements: ['value', null]);
-
- /** @When retrieving the last element with a default value */
- $actual = $collection->last(defaultValueIfNotFound: 'default');
-
- /** @Then the last element should be null */
- self::assertNull($actual);
- }
-
- public function testLastReturnsDefaultValueWhenCollectionIsEmpty(): void
- {
- /** @Given an empty collection */
- $collection = Collection::createFromEmpty();
-
- /** @When attempting to get the last element */
- $actual = $collection->last(defaultValueIfNotFound: 'default');
-
- /** @Then the result should be the default value */
- self::assertSame('default', $actual);
- }
-
- public function testLastReturnsLastElementFromSplDoublyLinkedList(): void
- {
- /** @Given a collection created from a SplDoublyLinkedList */
- $elements = new SplDoublyLinkedList();
- $elements->push('first');
- $elements->push('second');
- $elements->push('third');
- $collection = Collection::createFrom(elements: $elements);
-
- /** @When retrieving the last element */
- $actual = $collection->last();
-
- /** @Then the result should be the last value */
- self::assertSame('third', $actual);
- }
-
- public function testLastReturnsLastElementFromArrayAccessCountableIterable(): void
- {
- /** @Given a collection created from an ArrayIterator */
- $collection = Collection::createFrom(elements: new ArrayIterator(['alpha', 'beta', 'gamma']));
-
- /** @When retrieving the last element */
- $actual = $collection->last();
-
- /** @Then the result should be the last value */
- self::assertSame('gamma', $actual);
- }
-
- public function testLastReturnsNullWhenCollectionIsEmptyWithoutDefaultValue(): void
- {
- /** @Given an empty collection */
- $collection = Collection::createFromEmpty();
-
- /** @When retrieving the last element without a default value */
- $actual = $collection->last();
-
- /** @Then the last element should be null */
- self::assertNull($actual);
- }
-}
diff --git a/tests/Internal/Operations/Retrieve/CollectionSliceOperationTest.php b/tests/Internal/Operations/Retrieve/CollectionSliceOperationTest.php
deleted file mode 100644
index 12f9a43..0000000
--- a/tests/Internal/Operations/Retrieve/CollectionSliceOperationTest.php
+++ /dev/null
@@ -1,124 +0,0 @@
-slice(index: 1, length: 2);
-
- /** @Then the result should contain the sliced elements */
- self::assertSame([
- 1 => $elements[1]->toArray(),
- 2 => $elements[2]->toArray()
- ], $actual->toArray());
- }
-
- public function testSliceReturnsEmptyWhenIndexExceedsCollectionSize(): void
- {
- /** @Given a collection of CryptoCurrency objects */
- $elements = [
- new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'),
- new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'),
- new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB')
- ];
- $collection = Collection::createFrom(elements: $elements);
-
- /** @When slicing the collection with an index that exceeds the collection size */
- $actual = $collection->slice(index: 5, length: 2);
-
- /** @Then the result should be an empty array */
- self::assertEmpty($actual->toArray());
- }
-
- public function testSliceWithZeroLengthReturnsEmpty(): void
- {
- /** @Given a collection of CryptoCurrency objects */
- $elements = [
- new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'),
- new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'),
- new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB')
- ];
- $collection = Collection::createFrom(elements: $elements);
-
- /** @When slicing with length 0 */
- $actual = $collection->slice(index: 1, length: 0);
-
- /** @Then the result should be an empty array */
- self::assertEmpty($actual->toArray());
- }
-
- public function testSliceWithLengthOne(): void
- {
- /** @Given a collection of CryptoCurrency objects */
- $elements = [
- new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'),
- new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'),
- new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB')
- ];
- $collection = Collection::createFrom(elements: $elements);
-
- /** @When slicing with length 1 */
- $actual = $collection->slice(index: 1, length: 1);
-
- /** @Then the result should contain only one element */
- self::assertSame([1 => $elements[1]->toArray()], $actual->toArray());
- }
-
- public function testSliceWithNegativeTwoLength(): void
- {
- /** @Given a collection of CryptoCurrency objects */
- $elements = [
- new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'),
- new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'),
- new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB'),
- new CryptoCurrency(name: 'Cardano', price: 2.0, symbol: 'ADA')
- ];
- $collection = Collection::createFrom(elements: $elements);
-
- /** @When slicing with length -2 */
- $actual = $collection->slice(index: 1, length: -2);
-
- /** @Then the result should contain only the first element after the index */
- self::assertSame([1 => $elements[1]->toArray()], $actual->toArray());
- }
-
- public function testSliceWithoutPassingLength(): void
- {
- /** @Given a collection of CryptoCurrency objects */
- $elements = [
- new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'),
- new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'),
- new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB'),
- new CryptoCurrency(name: 'Cardano', price: 2.0, symbol: 'ADA')
- ];
- $collection = Collection::createFrom(elements: $elements);
-
- /** @When slicing without a passing length (defaults to -1) */
- $actual = $collection->slice(index: 1);
-
- /** @Then the result should contain all elements starting from the index */
- self::assertSame([
- 1 => $elements[1]->toArray(),
- 2 => $elements[2]->toArray(),
- 3 => $elements[3]->toArray()
- ], $actual->toArray());
- }
-}
diff --git a/tests/Internal/Operations/Transform/CollectionEachOperationTest.php b/tests/Internal/Operations/Transform/CollectionEachOperationTest.php
deleted file mode 100644
index 1d511e9..0000000
--- a/tests/Internal/Operations/Transform/CollectionEachOperationTest.php
+++ /dev/null
@@ -1,85 +0,0 @@
-each(function (Invoice $invoice) use ($summaries): void {
- $summaries->add(new InvoiceSummary(amount: $invoice->amount, customer: $invoice->customer));
- });
-
- /** @Then the invoice summaries should contain the mapped data */
- self::assertCount(3, $summaries);
-
- $expected = [
- ['amount' => 100.0, 'customer' => 'Customer A'],
- ['amount' => 150.5, 'customer' => 'Customer B'],
- ['amount' => 200.75, 'customer' => 'Customer C']
- ];
-
- self::assertEquals($expected, $summaries->toArray());
- }
-
- public function testEachWithMultipleActions(): void
- {
- /** @Given a collection with elements */
- $collection = Collection::createFrom(elements: [1, 2, 3]);
-
- /** @When executing multiple actions */
- $result = [];
- $collection->each(
- function (int $value) use (&$result): void {
- $result[] = $value + 1;
- },
- function (int $value) use (&$result): void {
- $result[] = $value * 2;
- }
- );
-
- /** @Then the result should reflect the actions */
- self::assertSame([2, 2, 3, 4, 4, 6], $result);
- }
-
- public function testPreserveKeysWithMultipleActions(): void
- {
- /** @Given a collection with associative array elements */
- $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]);
-
- /** @When executing actions and collecting the results */
- $result = [];
- $collection->each(
- function (int $value, string $key) use (&$result): void {
- $result[$key] = $value * 2;
- },
- function (int $value, string $key) use (&$result): void {
- $result[$key] += 1;
- }
- );
-
- /** @Then the result should contain the modified elements with preserved keys */
- self::assertSame(['a' => 3, 'b' => 5, 'c' => 7], $result);
- }
-}
diff --git a/tests/Internal/Operations/Transform/CollectionFlattenOperationTest.php b/tests/Internal/Operations/Transform/CollectionFlattenOperationTest.php
deleted file mode 100644
index ef3f34c..0000000
--- a/tests/Internal/Operations/Transform/CollectionFlattenOperationTest.php
+++ /dev/null
@@ -1,68 +0,0 @@
-flatten();
-
- /** @Then the collection should remain empty */
- self::assertEmpty($actual->toArray());
- }
-
- public function testFlattenNestedCollections(): void
- {
- /** @Given a collection of nested collections */
- $collection = Collection::createFrom(elements: [
- Collection::createFrom(elements: [1, 2]),
- Collection::createFrom(elements: [3, 4]),
- Collection::createFrom(elements: [5, 6])
- ]);
-
- /** @When flattening the collection */
- $actual = $collection->flatten();
-
- /** @Then the collection should contain all elements in a single collection */
- self::assertSame([1, 2, 3, 4, 5, 6], $actual->toArray());
- }
-
- public function testFlattenNonNestedCollection(): void
- {
- /** @Given a collection without any nested elements */
- $collection = Collection::createFrom(elements: [1, 2, 3]);
-
- /** @When flattening the collection */
- $actual = $collection->flatten();
-
- /** @Then the collection should remain unchanged */
- self::assertSame([1, 2, 3], $actual->toArray());
- }
-
- public function testFlattenWithMixedNestedElements(): void
- {
- /** @Given a collection with mixed nested and non-nested elements */
- $collection = Collection::createFrom(elements: [
- 1,
- Collection::createFrom(elements: [2, 3]),
- 4,
- Collection::createFrom(elements: [5, 6])
- ]);
-
- /** @When flattening the collection */
- $actual = $collection->flatten();
-
- /** @Then the collection should contain all elements in a single collection */
- self::assertSame([1, 2, 3, 4, 5, 6], $actual->toArray());
- }
-}
diff --git a/tests/Internal/Operations/Transform/CollectionGroupByOperationTest.php b/tests/Internal/Operations/Transform/CollectionGroupByOperationTest.php
deleted file mode 100644
index cce3fdf..0000000
--- a/tests/Internal/Operations/Transform/CollectionGroupByOperationTest.php
+++ /dev/null
@@ -1,98 +0,0 @@
-groupBy(grouping: static fn(Amount $amount): string => $amount->currency->name);
-
- /** @Then the collection should be grouped by the currency */
- $expected = Collection::createFrom(elements: [
- 'BRL' => [
- new Amount(value: 55.1, currency: Currency::BRL),
- new Amount(value: 23.3, currency: Currency::BRL)
- ],
- 'USD' => [
- new Amount(value: 100.5, currency: Currency::USD),
- new Amount(value: 200.0, currency: Currency::USD)
- ]
- ]);
-
- self::assertEquals($expected->toArray(), $actual->toArray());
- }
-
- public function testGroupBySimpleKey(): void
- {
- /** @Given a collection of elements with a type property */
- $collection = Collection::createFrom(elements: [
- ['type' => 'fruit', 'name' => 'apple'],
- ['type' => 'fruit', 'name' => 'banana'],
- ['type' => 'vegetable', 'name' => 'carrot'],
- ['type' => 'vegetable', 'name' => 'broccoli']
- ]);
-
- /** @When grouping by the 'type' key */
- $actual = $collection->groupBy(grouping: static fn(array $item): string => $item['type']);
-
- /** @Then the collection should be grouped by the type property */
- $expected = Collection::createFrom(elements: [
- 'fruit' => Collection::createFrom(elements: [
- ['type' => 'fruit', 'name' => 'apple'],
- ['type' => 'fruit', 'name' => 'banana']
- ]),
- 'vegetable' => Collection::createFrom(elements: [
- ['type' => 'vegetable', 'name' => 'carrot'],
- ['type' => 'vegetable', 'name' => 'broccoli']
- ])
- ]);
-
- self::assertSame($expected->toArray(), $actual->toArray());
- }
-
- public function testGroupByNumericKey(): void
- {
- /** @Given a collection of numbers */
- $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5, 6]);
-
- /** @When grouping by even and odd numbers */
- $actual = $collection->groupBy(grouping: static fn(int $item): string => $item % 2 === 0 ? 'even' : 'odd');
-
- /** @Then the collection should be grouped into even and odd */
- $expected = Collection::createFrom(elements: [
- 'odd' => Collection::createFrom(elements: [1, 3, 5]),
- 'even' => Collection::createFrom(elements: [2, 4, 6])
- ]);
-
- self::assertSame($expected->toArray(), $actual->toArray());
- }
-
- public function testGroupByEmptyCollection(): void
- {
- /** @Given an empty collection */
- $collection = Collection::createFromEmpty();
-
- /** @When applying groupBy on the empty collection */
- $actual = $collection->groupBy(grouping: static fn(array $item): array => $item);
-
- /** @Then the collection should remain empty */
- self::assertEmpty($actual->toArray());
- }
-}
diff --git a/tests/Internal/Operations/Transform/CollectionJoinToStringTest.php b/tests/Internal/Operations/Transform/CollectionJoinToStringTest.php
deleted file mode 100644
index 47946f7..0000000
--- a/tests/Internal/Operations/Transform/CollectionJoinToStringTest.php
+++ /dev/null
@@ -1,95 +0,0 @@
-joinToString(separator: ',');
-
- /** @Then the result should be a string with elements joined by the separator */
- self::assertSame('1,2,3', $actual);
- }
-
- public function testJoinToStringWithEmptyCollection(): void
- {
- /** @Given an empty collection */
- $collection = Collection::createFromEmpty();
-
- /** @When joining the empty collection elements with a separator */
- $actual = $collection->joinToString(separator: ',');
-
- /** @Then the result should be an empty string */
- self::assertSame('', $actual);
- }
-
- public function testJoinToStringWithCustomSeparator(): void
- {
- /** @Given a collection of strings */
- $collection = Collection::createFrom(elements: ['apple', 'banana', 'cherry']);
-
- /** @When joining the collection elements with a dash separator */
- $actual = $collection->joinToString(separator: '-');
-
- /** @Then the result should be a string with elements joined by the custom separator */
- self::assertSame('apple-banana-cherry', $actual);
- }
-
- public function testJoinToStringWithSingleElement(): void
- {
- /** @Given a collection with a single element */
- $collection = Collection::createFrom(elements: ['onlyOne']);
-
- /** @When joining the collection elements with a space separator */
- $actual = $collection->joinToString(separator: ',');
-
- /** @Then the result should be the single element as a string */
- self::assertSame('onlyOne', $actual);
- }
-
- public function testJoinToStringWithNonStringElements(): void
- {
- /** @Given a collection of mixed elements */
- $collection = Collection::createFrom(elements: [1, 2.5, true, null]);
-
- /** @When joining the collection elements with a comma separator */
- $actual = $collection->joinToString(separator: ',');
-
- /** @Then the result should be a string representation of the elements joined by the separator */
- self::assertSame('1,2.5,1,', $actual);
- }
-
- public function testJoinToStringWithSpaceSeparator(): void
- {
- /** @Given a collection of integers */
- $collection = Collection::createFrom(elements: [1, 2, 3]);
-
- /** @When joining the collection elements with a space separator */
- $actual = $collection->joinToString(separator: ' ');
-
- /** @Then the result should be a string with elements joined by the space separator */
- self::assertSame('1 2 3', $actual);
- }
-
- public function testJoinToStringWithStringElementsAndSpaceSeparator(): void
- {
- /** @Given a collection of strings */
- $collection = Collection::createFrom(elements: ['apple', 'banana', 'cherry']);
-
- /** @When joining the collection elements with a space separator */
- $actual = $collection->joinToString(separator: ' ');
-
- /** @Then the result should be a string with elements joined by the space separator */
- self::assertSame('apple banana cherry', $actual);
- }
-}
diff --git a/tests/Internal/Operations/Transform/CollectionMapOperationTest.php b/tests/Internal/Operations/Transform/CollectionMapOperationTest.php
deleted file mode 100644
index d2de4b6..0000000
--- a/tests/Internal/Operations/Transform/CollectionMapOperationTest.php
+++ /dev/null
@@ -1,94 +0,0 @@
- 'Smaug', 'description' => 'Fire-breathing dragon'],
- ['name' => 'Shenron', 'description' => 'Eternal dragon'],
- ['name' => 'Toothless', 'description' => 'Night Fury dragon']
- ]);
-
- /** @When mapped to convert arrays into objects */
- $actual = $collection->map(static function (iterable $data): Dragon {
- return new Dragon(name: $data['name'], description: $data['description']);
- });
-
- /** @Then the collection should contain transformed objects */
- $expected = Collection::createFrom(elements: [
- new Dragon(name: 'Smaug', description: 'Fire-breathing dragon'),
- new Dragon(name: 'Shenron', description: 'Eternal dragon'),
- new Dragon(name: 'Toothless', description: 'Night Fury dragon')
- ]);
-
- self::assertSame($expected->toArray(), $actual->toArray());
- }
-
- public function testMapPreservesKeys(): void
- {
- /** @Given a collection with associative array elements */
- $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]);
-
- /** @When mapping the collection with a transformation */
- $actual = $collection->map(transformations: static fn(int $value): int => $value * 2);
-
- /** @Then the mapped collection should preserve the keys */
- self::assertSame(['a' => 2, 'b' => 4, 'c' => 6], $actual->toArray());
- }
-
- public function testMapEmptyCollection(): void
- {
- /** @Given an empty collection */
- $collection = Collection::createFromEmpty();
-
- /** @When mapping the empty collection with transformations */
- $actual = $collection->map(
- static fn(int $value): int => $value * 2,
- static fn(int $value): int => $value + 1
- );
-
- /** @Then the collection should remain empty */
- self::assertEmpty($actual->toArray());
- }
-
- public function testMapWithSingleTransformation(): void
- {
- /** @Given a collection with elements */
- $collection = Collection::createFrom(elements: [1, 2, 3]);
-
- /** @When mapping the collection with a transformation */
- $actual = $collection->map(transformations: static fn(int $value): int => $value * 2);
-
- /** @Then the collection should contain transformed elements */
- self::assertSame([2, 4, 6], $actual->toArray());
- }
-
- public function testMapWithMultipleTransformations(): void
- {
- /** @Given a collection with elements */
- $collection = Collection::createFrom(elements: [1, 2, 3]);
-
- /**
- * @When mapping the collection with two transformations,
- * the first transformation squares each value,
- * and the second transformation increments each value by 1.
- */
- $actual = $collection->map(
- static fn(int $value): int => $value * $value,
- static fn(int $value): int => $value + 1
- );
-
- /** @Then the collection should contain elements transformed by the transformations */
- self::assertSame([2, 5, 10], $actual->toArray());
- }
-}
diff --git a/tests/Internal/Operations/Transform/CollectionMapToArrayOperationTest.php b/tests/Internal/Operations/Transform/CollectionMapToArrayOperationTest.php
deleted file mode 100644
index a472a19..0000000
--- a/tests/Internal/Operations/Transform/CollectionMapToArrayOperationTest.php
+++ /dev/null
@@ -1,94 +0,0 @@
-toArray();
-
- /** @Then the array representation should match the expected format */
- self::assertSame($expected, $actual);
- self::assertSame(count((array)$expected), $collection->count());
- }
-
- public static function elementsDataProvider(): iterable
- {
- $amountInBrl = new Amount(value: 55.1, currency: Currency::BRL);
- $amountInUsd = new Amount(value: 55.2, currency: Currency::USD);
-
- yield 'Convert unit enums to array' => [
- 'elements' => [Currency::USD, Currency::BRL],
- 'expected' => [Currency::USD->name, Currency::BRL->name]
- ];
-
- yield 'Convert mixed types to array' => [
- 'elements' => ['iPhone', 42, true, $amountInUsd],
- 'expected' => [
- 'iPhone',
- 42,
- true,
- ['value' => $amountInUsd->value, 'currency' => $amountInUsd->currency->name]
- ]
- ];
-
- yield 'Convert backed enums to array' => [
- 'elements' => [Status::PAID, Status::PENDING],
- 'expected' => [Status::PAID->value, Status::PENDING->value]
- ];
-
- yield 'Convert nested arrays to array' => [
- 'elements' => [
- ['name' => 'Item 1', 'details' => ['price' => 100, 'stock' => 10]],
- ['name' => 'Item 2', 'details' => ['price' => 200, 'stock' => 5]]
- ],
- 'expected' => [
- ['name' => 'Item 1', 'details' => ['price' => 100, 'stock' => 10]],
- ['name' => 'Item 2', 'details' => ['price' => 200, 'stock' => 5]]
- ]
- ];
-
- yield 'Convert boolean values to array' => [
- 'elements' => [true, false],
- 'expected' => [true, false]
- ];
-
- yield 'Convert empty collection to array' => [
- 'elements' => [],
- 'expected' => []
- ];
-
- yield 'Convert array of strings to array' => [
- 'elements' => ['iPhone', 'iPad', 'MacBook'],
- 'expected' => ['iPhone', 'iPad', 'MacBook']
- ];
-
- yield 'Convert array of integers to array' => [
- 'elements' => [1, 2, 3],
- 'expected' => [1, 2, 3]
- ];
-
- yield 'Convert array of amount objects to array' => [
- 'elements' => [$amountInBrl, $amountInUsd],
- 'expected' => [
- ['value' => $amountInBrl->value, 'currency' => $amountInBrl->currency->name],
- ['value' => $amountInUsd->value, 'currency' => $amountInUsd->currency->name]
- ]
- ];
- }
-}
diff --git a/tests/Internal/Operations/Transform/CollectionMapToJsonOperationTest.php b/tests/Internal/Operations/Transform/CollectionMapToJsonOperationTest.php
deleted file mode 100644
index c717f10..0000000
--- a/tests/Internal/Operations/Transform/CollectionMapToJsonOperationTest.php
+++ /dev/null
@@ -1,82 +0,0 @@
-toJson();
-
- /** @Then the JSON representation should match the expected format */
- self::assertSame($expected, $actual);
- }
-
- public static function elementsDataProvider(): iterable
- {
- $amountInBrl = new Amount(value: 55.1, currency: Currency::BRL);
- $amountInUsd = new Amount(value: 55.2, currency: Currency::USD);
-
- yield 'Convert unit enums to JSON' => [
- 'elements' => [Currency::USD, Currency::BRL],
- 'expected' => '["USD","BRL"]'
- ];
-
- yield 'Convert mixed types to JSON' => [
- 'elements' => ['iPhone', 42, true, $amountInUsd],
- 'expected' => '["iPhone",42,true,{"value":55.2,"currency":"USD"}]'
- ];
-
- yield 'Convert backed enums to JSON' => [
- 'elements' => [Status::PAID, Status::PENDING],
- 'expected' => '[1,0]'
- ];
-
- yield 'Convert nested arrays to JSON' => [
- 'elements' => [
- ['name' => 'Item 1', 'details' => ['price' => 100, 'stock' => 10]],
- ['name' => 'Item 2', 'details' => ['price' => 200, 'stock' => 5]]
- ],
- 'expected' => '[{"name":"Item 1","details":{"price":100,"stock":10}},{"name":"Item 2","details":{"price":200,"stock":5}}]'
- ];
-
- yield 'Convert boolean values to JSON' => [
- 'elements' => [true, false],
- 'expected' => '[true,false]'
- ];
-
- yield 'Convert empty collection to JSON' => [
- 'elements' => [],
- 'expected' => '[]'
- ];
-
- yield 'Convert array of strings to JSON' => [
- 'elements' => ['iPhone', 'iPad', 'MacBook'],
- 'expected' => '["iPhone","iPad","MacBook"]'
- ];
-
- yield 'Convert array of integers to JSON' => [
- 'elements' => [1, 2, 3],
- 'expected' => '[1,2,3]'
- ];
-
- yield 'Convert array of amount objects to JSON' => [
- 'elements' => [$amountInBrl, $amountInUsd],
- 'expected' => '[{"value":55.1,"currency":"BRL"},{"value":55.2,"currency":"USD"}]'
- ];
- }
-}
diff --git a/tests/Internal/Operations/Write/CollectionAddOperationTest.php b/tests/Internal/Operations/Write/CollectionAddOperationTest.php
deleted file mode 100644
index ac1c8e6..0000000
--- a/tests/Internal/Operations/Write/CollectionAddOperationTest.php
+++ /dev/null
@@ -1,128 +0,0 @@
-add(...$addElements);
-
- /** @Then the collection should contain the expected elements */
- self::assertSame($expected, $collection->toArray());
- }
-
- public static function elementsAndAdditionsDataProvider(): iterable
- {
- $dragonOne = new Dragon(name: 'Udron', description: 'The taker of life.');
- $dragonTwo = new Dragon(name: 'Ignarion', description: 'Majestic guardian of fiery realms.');
- $dragonThree = new Dragon(name: 'Ignivar Bloodwing', description: 'Fierce guardian of volcanic mountains.');
-
- $bitcoin = new CryptoCurrency(name: 'Bitcoin', price: (float)rand(60000, 999999), symbol: 'BTC');
- $ethereum = new CryptoCurrency(name: 'Ethereum', price: (float)rand(10000, 60000), symbol: 'ETH');
-
- $productOne = new Product(name: 'Product One', amount: new Amount(value: 100.50, currency: Currency::USD));
- $productTwo = new Product(name: 'Product Two', amount: new Amount(value: 200.75, currency: Currency::BRL));
-
- $orderOne = new Order(id: 1, products: new Products(elements: [$productOne, $productTwo]));
- $orderTwo = new Order(id: 2, products: new Products(elements: [$productOne]));
-
- yield 'Add null value' => [
- 'fromElements' => [],
- 'addElements' => [null],
- 'expected' => [null]
- ];
-
- yield 'Add single array' => [
- 'fromElements' => [],
- 'addElements' => [[1, 2, 3]],
- 'expected' => [[1, 2, 3]]
- ];
-
- yield 'Add single float' => [
- 'fromElements' => [],
- 'addElements' => [3.14],
- 'expected' => [3.14]
- ];
-
- yield 'Add single string' => [
- 'fromElements' => [],
- 'addElements' => ['test'],
- 'expected' => ['test']
- ];
-
- yield 'Add orders objects' => [
- 'fromElements' => [$orderOne],
- 'addElements' => [$orderTwo],
- 'expected' => [
- [
- 'id' => 1,
- 'products' => [$productOne->toArray(), $productTwo->toArray()]
- ],
- [
- 'id' => 2,
- 'products' => [$productOne->toArray()]
- ]
- ]
- ];
-
- yield 'Add dragon objects' => [
- 'fromElements' => [$dragonOne],
- 'addElements' => [$dragonTwo, $dragonThree],
- 'expected' => [
- ['name' => $dragonOne->name, 'description' => $dragonOne->description],
- ['name' => $dragonTwo->name, 'description' => $dragonTwo->description],
- ['name' => $dragonThree->name, 'description' => $dragonThree->description]
- ]
- ];
-
- yield 'Add boolean values' => [
- 'fromElements' => [],
- 'addElements' => [true, false],
- 'expected' => [true, false]
- ];
-
- yield 'Add single integer' => [
- 'fromElements' => [],
- 'addElements' => [42],
- 'expected' => [42]
- ];
-
- yield 'Add crypto currency objects' => [
- 'fromElements' => [$bitcoin],
- 'addElements' => [$ethereum],
- 'expected' => [
- ['name' => $bitcoin->name, 'price' => $bitcoin->price, 'symbol' => $bitcoin->symbol],
- ['name' => $ethereum->name, 'price' => $ethereum->price, 'symbol' => $ethereum->symbol]
- ]
- ];
-
- yield 'Add mixed types of primitives' => [
- 'fromElements' => [],
- 'addElements' => [42, 'test', 3.14, null, [1, 2, 3]],
- 'expected' => [42, 'test', 3.14, null, [1, 2, 3]]
- ];
- }
-}
diff --git a/tests/Internal/Operations/Write/CollectionCreateOperationTest.php b/tests/Internal/Operations/Write/CollectionCreateOperationTest.php
deleted file mode 100644
index 31dfe9b..0000000
--- a/tests/Internal/Operations/Write/CollectionCreateOperationTest.php
+++ /dev/null
@@ -1,47 +0,0 @@
-isEmpty());
- }
-
- public function testCreatingCollectionFromExistingCollection(): void
- {
- /** @Given a collection of cryptocurrencies */
- $collection = Collection::createFrom(elements: [
- new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'),
- new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'),
- ]);
-
- /** @When creating another collection from the existing collection */
- $collectionB = Collection::createFrom(elements: $collection);
-
- /** @Then the new collection should have the same number of elements */
- self::assertCount($collection->count(), $collectionB);
-
- /** @And both collections should be equal */
- self::assertTrue($collectionB->equals(other: $collection));
-
- /** @And the elements in both collections should match */
- foreach ($collection as $index => $element) {
- self::assertEquals($element, $collectionB->getBy(index: $index));
- }
-
- /** @And the two collections should not be the same instance */
- self::assertNotSame($collection, $collectionB);
- }
-}
diff --git a/tests/Internal/Operations/Write/CollectionMergeOperationTest.php b/tests/Internal/Operations/Write/CollectionMergeOperationTest.php
deleted file mode 100644
index d0eebd5..0000000
--- a/tests/Internal/Operations/Write/CollectionMergeOperationTest.php
+++ /dev/null
@@ -1,116 +0,0 @@
-merge(other: $collectionB);
-
- /** @Then the result should be an empty collection */
- self::assertEmpty($actual->toArray());
- }
-
- public function testMergeIntoEmptyCollection(): void
- {
- /** @Given an empty collection and a non-empty collection */
- $collectionA = Collection::createFromEmpty();
- $collectionB = Collection::createFrom(elements: [4, 5, 6]);
-
- /** @When merging the non-empty collection into the empty one */
- $actual = $collectionA->merge(other: $collectionB);
-
- /** @Then the result should contain only the elements from the non-empty collection */
- self::assertSame([4, 5, 6], $actual->toArray());
- }
-
- public function testMergeWithEmptyCollection(): void
- {
- /** @Given a non-empty collection and an empty collection */
- $collectionA = Collection::createFrom(elements: [1, 2, 3]);
- $collectionB = Collection::createFromEmpty();
-
- /** @When merging the empty collection into the non-empty one */
- $actual = $collectionA->merge(other: $collectionB);
-
- /** @Then the result should contain only the original elements */
- self::assertSame([1, 2, 3], $actual->toArray());
- }
-
- public function testMergeTwoCollections(): void
- {
- /** @Given two collections with distinct elements */
- $collectionA = Collection::createFrom(elements: [1, 2, 3]);
- $collectionB = Collection::createFrom(elements: [4, 5, 6]);
-
- /** @When merging collection B into collection A */
- $actual = $collectionA->merge(other: $collectionB);
-
- /** @Then the result should contain all elements in order */
- self::assertSame([1, 2, 3, 4, 5, 6], $actual->toArray());
- }
-
- public function testMergePreservesLazyEvaluation(): void
- {
- /** @Given two collections created from generators */
- $collectionA = Collection::createFrom(
- elements: (static function () {
- yield 1;
- yield 2;
- })()
- );
-
- $collectionB = Collection::createFrom(
- elements: (static function () {
- yield 3;
- yield 4;
- })()
- );
-
- /** @When merging and retrieving only the first element */
- $actual = $collectionA->merge(other: $collectionB)->first();
-
- /** @Then the first element should be from collection A without materializing all elements */
- self::assertSame(1, $actual);
- }
-
- public function testMergeMultipleCollections(): void
- {
- /** @Given three collections */
- $collectionA = Collection::createFrom(elements: [1, 2]);
- $collectionB = Collection::createFrom(elements: [3, 4]);
- $collectionC = Collection::createFrom(elements: [5, 6]);
-
- /** @When chaining multiple merge operations */
- $actual = $collectionA
- ->merge(other: $collectionB)
- ->merge(other: $collectionC);
-
- /** @Then the result should contain all elements in order */
- self::assertSame([1, 2, 3, 4, 5, 6], $actual->toArray());
- }
-
- public function testMergeWithDuplicateElements(): void
- {
- /** @Given two collections with overlapping elements */
- $collectionA = Collection::createFrom(elements: [1, 2, 3]);
- $collectionB = Collection::createFrom(elements: [3, 4, 5]);
-
- /** @When merging the collections */
- $actual = $collectionA->merge(other: $collectionB);
-
- /** @Then the result should contain all elements including duplicates */
- self::assertSame([1, 2, 3, 3, 4, 5], $actual->toArray());
- }
-}
diff --git a/tests/Internal/Operations/Write/CollectionRemoveOperationTest.php b/tests/Internal/Operations/Write/CollectionRemoveOperationTest.php
deleted file mode 100644
index e977021..0000000
--- a/tests/Internal/Operations/Write/CollectionRemoveOperationTest.php
+++ /dev/null
@@ -1,125 +0,0 @@
-removeAll();
-
- /** @Then the collection should be empty */
- self::assertEmpty($actual->toArray());
- }
-
- #[DataProvider('elementRemovalDataProvider')]
- public function testRemoveSpecificElement(mixed $element, iterable $elements, iterable $expected): void
- {
- /** @Given a collection created from initial elements */
- $collection = Collection::createFrom(elements: $elements);
-
- /** @When removing the specified element */
- $actual = $collection->remove(element: $element);
-
- /** @Then the collection should no longer contain the removed element */
- self::assertSame($expected, $actual->toArray());
- }
-
- public function testRemoveSpecificElementUsingFilter(): void
- {
- /** @Given a Bitcoin (BTC) */
- $bitcoin = new CryptoCurrency(name: 'Bitcoin', price: (float)rand(60000, 999999), symbol: 'BTC');
-
- /** @And an Ethereum (ETH) */
- $ethereum = new CryptoCurrency(name: 'Ethereum', price: (float)rand(10000, 60000), symbol: 'ETH');
-
- /** @And a collection containing these elements */
- $collection = Collection::createFrom(elements: [$bitcoin, $ethereum]);
-
- /** @When removing the Bitcoin (BTC) element using a filter */
- $actual = $collection->removeAll(filter: static fn(CryptoCurrency $item) => $item === $bitcoin);
-
- /** @Then the collection should no longer contain the removed element */
- self::assertSame([$ethereum->toArray()], $actual->toArray());
- }
-
- public static function elementRemovalDataProvider(): iterable
- {
- $dragonOne = new Dragon(name: 'Udron', description: 'The taker of life.');
- $dragonTwo = new Dragon(name: 'Ignarion', description: 'Majestic guardian of fiery realms.');
-
- $bitcoin = new CryptoCurrency(name: 'Bitcoin', price: (float)rand(60000, 999999), symbol: 'BTC');
- $ethereum = new CryptoCurrency(name: 'Ethereum', price: (float)rand(10000, 60000), symbol: 'ETH');
-
- $spTimeZone = new DateTimeZone('America/Sao_Paulo');
- $dateOne = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '1997-01-01 00:00:00', $spTimeZone);
- $dateTwo = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '1997-01-02 00:00:00', $spTimeZone);
-
- yield 'Remove enum from collection' => [
- 'element' => Currency::BRL,
- 'elements' => [Currency::BRL, Status::PAID, Currency::USD, Status::PENDING],
- 'expected' => [Status::PAID->value, Currency::USD->name, Status::PENDING->value]
- ];
-
- yield 'Remove null from collection' => [
- 'element' => null,
- 'elements' => [$ethereum, null, $bitcoin],
- 'expected' => [$ethereum->toArray(), $bitcoin->toArray()]
- ];
-
- yield 'Remove date from collection' => [
- 'element' => $dateOne,
- 'elements' => [$dateOne, $dateTwo],
- 'expected' => ['1997-01-02T00:00:00-02:00']
- ];
-
- yield 'Remove dragon from collection' => [
- 'element' => $dragonOne,
- 'elements' => [$dragonOne, $dragonTwo],
- 'expected' => [
- ['name' => $dragonTwo->name, 'description' => $dragonTwo->description]
- ]
- ];
-
- yield 'Remove scalar values from collection' => [
- 'element' => 50,
- 'elements' => [true, 100, 'xpto', 50, 1000.0001, null, ['id' => 1]],
- 'expected' => [true, 100, 'xpto', 1000.0001, null, ['id' => 1]]
- ];
-
- yield 'Remove crypto currency from collection' => [
- 'element' => $bitcoin,
- 'elements' => [$ethereum, null, $bitcoin],
- 'expected' => [$ethereum->toArray(), null]
- ];
-
- yield 'Remove list of elements from array iterators' => [
- 'element' => [4, 5, 6],
- 'elements' => new ArrayIterator([[1, 2, 3], [4, 5, 6], [7, 8, 9]]),
- 'expected' => [[1, 2, 3], [7, 8, 9]]
- ];
- }
-}
diff --git a/tests/LazyCollectionTest.php b/tests/LazyCollectionTest.php
new file mode 100644
index 0000000..dfe324a
--- /dev/null
+++ b/tests/LazyCollectionTest.php
@@ -0,0 +1,1043 @@
+count());
+
+ /** @And the array should match the original elements */
+ self::assertSame([1, 2, 3], $collection->toArray());
+ }
+
+ public function testFromEmpty(): void
+ {
+ /** @When creating a lazy collection without arguments */
+ $collection = Collection::createLazyFromEmpty();
+
+ /** @Then the collection should be empty */
+ self::assertTrue($collection->isEmpty());
+
+ /** @And the count should be zero */
+ self::assertSame(0, $collection->count());
+ }
+
+ public function testFromGenerator(): void
+ {
+ /** @Given a generator that yields three elements */
+ $generator = (static function (): Generator {
+ yield 1;
+ yield 2;
+ yield 3;
+ })();
+
+ /** @When creating a lazy collection from the generator */
+ $collection = Collection::createLazyFrom(elements: $generator);
+
+ /** @Then the collection should contain all three elements */
+ self::assertSame(3, $collection->count());
+ }
+
+ public function testAdd(): void
+ {
+ /** @Given a lazy collection with three elements */
+ $collection = Collection::createLazyFrom(elements: [1, 2, 3]);
+
+ /** @When adding two more elements */
+ $actual = $collection->add(4, 5);
+
+ /** @Then the new collection should contain five elements */
+ self::assertSame(5, $actual->count());
+
+ /** @And the elements should be in the expected order */
+ self::assertSame([1, 2, 3, 4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+
+ /** @And the original collection should remain unchanged */
+ self::assertSame(3, $collection->count());
+ }
+
+ public function testConcat(): void
+ {
+ /** @Given a first lazy collection */
+ $first = Collection::createLazyFrom(elements: [1, 2]);
+
+ /** @And a second lazy collection */
+ $second = Collection::createLazyFrom(elements: [3, 4]);
+
+ /** @When concatenating the second into the first */
+ $actual = $first->merge(other: $second);
+
+ /** @Then the resulting collection should contain four elements */
+ self::assertSame(4, $actual->count());
+
+ /** @And the elements should be in the expected order */
+ self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testContainsExistingElement(): void
+ {
+ /** @Given a lazy collection with integers */
+ $collection = Collection::createLazyFrom(elements: [1, 2, 3]);
+
+ /** @When checking for an element that exists */
+ $actual = $collection->contains(element: 2);
+
+ /** @Then it should return true */
+ self::assertTrue($actual);
+ }
+
+ public function testContainsMissingElement(): void
+ {
+ /** @Given a lazy collection with integers */
+ $collection = Collection::createLazyFrom(elements: [1, 2, 3]);
+
+ /** @When checking for an element that does not exist */
+ $actual = $collection->contains(element: 99);
+
+ /** @Then it should return false */
+ self::assertFalse($actual);
+ }
+
+ public function testContainsObject(): void
+ {
+ /** @Given an Amount object to search for */
+ $target = new Amount(value: 100.00, currency: Currency::USD);
+
+ /** @And a lazy collection with Amount objects */
+ $collection = Collection::createLazyFrom(elements: [
+ new Amount(value: 50.00, currency: Currency::USD),
+ new Amount(value: 100.00, currency: Currency::USD),
+ new Amount(value: 200.00, currency: Currency::USD)
+ ]);
+
+ /** @When checking if the collection contains an equivalent Amount */
+ $actual = $collection->contains(element: $target);
+
+ /** @Then it should return true */
+ self::assertTrue($actual);
+ }
+
+ public function testContainsObjectDoesNotMatchTrueScalar(): void
+ {
+ /** @Given a lazy collection containing boolean true */
+ $collection = Collection::createLazyFrom(elements: [true]);
+
+ /** @When checking if the collection contains an object */
+ $actual = $collection->contains(element: new stdClass());
+
+ /** @Then it should return false because object and scalar types differ */
+ self::assertFalse($actual);
+ }
+
+ public function testCollectionWithObjectDoesNotContainTrueScalar(): void
+ {
+ /** @Given a lazy collection containing a stdClass object */
+ $collection = Collection::createLazyFrom(elements: [new stdClass()]);
+
+ /** @When checking if the collection contains boolean true */
+ $actual = $collection->contains(element: true);
+
+ /** @Then it should return false because an object is not a scalar */
+ self::assertFalse($actual);
+ }
+
+ public function testCount(): void
+ {
+ /** @Given a lazy collection with five elements */
+ $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5]);
+
+ /** @When counting the elements */
+ $actual = $collection->count();
+
+ /** @Then it should return 5 */
+ self::assertSame(5, $actual);
+ }
+
+ public function testFindFirstMatch(): void
+ {
+ /** @Given a lazy collection of integers */
+ $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5]);
+
+ /** @When finding the first element greater than 3 */
+ $actual = $collection->findBy(predicates: static fn(int $value): bool => $value > 3);
+
+ /** @Then it should return 4 */
+ self::assertSame(4, $actual);
+ }
+
+ public function testFindFirstMatchAcrossMultiplePredicates(): void
+ {
+ /** @Given a lazy collection of integers */
+ $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5]);
+
+ /** @When finding by multiple predicates (OR semantics) */
+ $matchTen = static fn(int $value): bool => $value === 10;
+ $matchThree = static fn(int $value): bool => $value === 3;
+
+ $actual = $collection->findBy($matchTen, $matchThree);
+
+ /** @Then it should return the first element matching any predicate */
+ self::assertSame(3, $actual);
+ }
+
+ public function testFindReturnsNullWithoutPredicates(): void
+ {
+ /** @Given a lazy collection with truthy and falsy values */
+ $collection = Collection::createLazyFrom(elements: [0, 1, 2]);
+
+ /** @When finding without predicates */
+ $actual = $collection->findBy();
+
+ /** @Then it should return null */
+ self::assertNull($actual);
+ }
+
+ public function testFindReturnsNullWhenNoMatch(): void
+ {
+ /** @Given a lazy collection of integers */
+ $collection = Collection::createLazyFrom(elements: [1, 2, 3]);
+
+ /** @When finding an element greater than 100 */
+ $actual = $collection->findBy(predicates: static fn(int $value): bool => $value > 100);
+
+ /** @Then it should return null */
+ self::assertNull($actual);
+ }
+
+ public function testEach(): void
+ {
+ /** @Given a lazy collection of integers */
+ $collection = Collection::createLazyFrom(elements: [1, 2, 3]);
+
+ /** @And a variable to accumulate a sum */
+ $sum = 0;
+
+ /** @When using each to accumulate the sum */
+ $actual = $collection->each(actions: function (int $value) use (&$sum): void {
+ $sum += $value;
+ });
+
+ /** @Then the sum should be 6 */
+ self::assertSame(6, $sum);
+
+ /** @And the returned collection should be the same instance */
+ self::assertSame($collection, $actual);
+ }
+
+ public function testEqualsWithIdenticalCollections(): void
+ {
+ /** @Given a first lazy collection */
+ $first = Collection::createLazyFrom(elements: [1, 2, 3]);
+
+ /** @And a second lazy collection with the same elements */
+ $second = Collection::createLazyFrom(elements: [1, 2, 3]);
+
+ /** @When comparing them for equality */
+ $actual = $first->equals(other: $second);
+
+ /** @Then they should be equal */
+ self::assertTrue($actual);
+ }
+
+ public function testEqualsWithDifferentCollections(): void
+ {
+ /** @Given a first lazy collection */
+ $first = Collection::createLazyFrom(elements: [1, 2, 3]);
+
+ /** @And a second lazy collection with different elements */
+ $second = Collection::createLazyFrom(elements: [1, 2, 4]);
+
+ /** @When comparing them for equality */
+ $actual = $first->equals(other: $second);
+
+ /** @Then they should not be equal */
+ self::assertFalse($actual);
+ }
+
+ public function testEqualsWithDifferentSizes(): void
+ {
+ /** @Given a first lazy collection with three elements */
+ $first = Collection::createLazyFrom(elements: [1, 2, 3]);
+
+ /** @And a second lazy collection with two elements */
+ $second = Collection::createLazyFrom(elements: [1, 2]);
+
+ /** @When comparing first equals second */
+ $firstEqualsSecond = $first->equals(other: $second);
+
+ /** @And comparing second equals first */
+ $secondEqualsFirst = $second->equals(other: $first);
+
+ /** @Then the first comparison should return false */
+ self::assertFalse($firstEqualsSecond);
+
+ /** @And the second comparison should return false */
+ self::assertFalse($secondEqualsFirst);
+ }
+
+ public function testEqualsWithDifferentSizesButSamePrefix(): void
+ {
+ /** @Given a first lazy collection with four elements */
+ $first = Collection::createLazyFrom(elements: [1, 2, 3, 4]);
+
+ /** @And a second lazy collection with three elements */
+ $second = Collection::createLazyFrom(elements: [1, 2, 3]);
+
+ /** @When comparing them for equality */
+ $actual = $first->equals(other: $second);
+
+ /** @Then they should not be equal */
+ self::assertFalse($actual);
+ }
+
+ public function testEqualsWithNullElementsAndDifferentSizes(): void
+ {
+ /** @Given a first lazy collection with three elements */
+ $first = Collection::createLazyFrom(elements: [1, 2, 3]);
+
+ /** @And a second lazy collection with four elements ending with null */
+ $second = Collection::createLazyFrom(elements: [1, 2, 3, null]);
+
+ /** @When comparing them for equality */
+ $actual = $first->equals(other: $second);
+
+ /** @Then they should not be equal */
+ self::assertFalse($actual);
+ }
+
+ public function testRemoveElement(): void
+ {
+ /** @Given a lazy collection with duplicate elements */
+ $collection = Collection::createLazyFrom(elements: [1, 2, 3, 2, 4]);
+
+ /** @When removing the value 2 */
+ $actual = $collection->remove(element: 2);
+
+ /** @Then all occurrences of 2 should be removed */
+ self::assertSame([1, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testRemoveScalarFromObjectCollection(): void
+ {
+ /** @Given a lazy collection with Amount objects */
+ $collection = Collection::createLazyFrom(elements: [
+ new Amount(value: 50.00, currency: Currency::USD),
+ new Amount(value: 100.00, currency: Currency::USD)
+ ]);
+
+ /** @When removing a scalar value */
+ $actual = $collection->remove(element: 50.00);
+
+ /** @Then no elements should be removed */
+ self::assertSame(2, $actual->count());
+ }
+
+ public function testRemovePreservesKeys(): void
+ {
+ /** @Given a lazy collection with string keys */
+ $collection = Collection::createLazyFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]);
+
+ /** @When removing the value 2 */
+ $actual = $collection->remove(element: 2);
+
+ /** @Then the remaining keys should be preserved */
+ self::assertSame(['a' => 1, 'c' => 3], $actual->toArray());
+ }
+
+ public function testRemoveAllWithPredicate(): void
+ {
+ /** @Given a lazy collection of integers */
+ $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5]);
+
+ /** @When removing all elements greater than 3 */
+ $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 3);
+
+ /** @Then only elements 1, 2, 3 should remain */
+ self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testRemoveAllWithoutPredicate(): void
+ {
+ /** @Given a lazy collection of integers */
+ $collection = Collection::createLazyFrom(elements: [1, 2, 3]);
+
+ /** @When removing all without a predicate */
+ $actual = $collection->removeAll();
+
+ /** @Then the collection should be empty */
+ self::assertSame(0, $actual->count());
+ }
+
+ public function testRemoveAllWithNonMatchingFirstElement(): void
+ {
+ /** @Given a lazy collection where the first element does not match the predicate */
+ $collection = Collection::createLazyFrom(elements: [1, 10, 2, 20, 3]);
+
+ /** @When removing all elements greater than 5 */
+ $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 5);
+
+ /** @Then elements 1, 2, 3 should remain */
+ self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testRemoveAllPreservesKeys(): void
+ {
+ /** @Given a lazy collection with string keys */
+ $collection = Collection::createLazyFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]);
+
+ /** @When removing elements greater than 2 */
+ $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 2);
+
+ /** @Then the remaining keys should be preserved */
+ self::assertSame(['a' => 1, 'b' => 2], $actual->toArray());
+ }
+
+ public function testFirstReturnsElement(): void
+ {
+ /** @Given a lazy collection with three elements */
+ $collection = Collection::createLazyFrom(elements: [10, 20, 30]);
+
+ /** @When retrieving the first element */
+ $actual = $collection->first();
+
+ /** @Then it should return 10 */
+ self::assertSame(10, $actual);
+ }
+
+ public function testFirstReturnsDefaultWhenEmpty(): void
+ {
+ /** @Given an empty lazy collection */
+ $collection = Collection::createLazyFromEmpty();
+
+ /** @When retrieving the first element with a default */
+ $actual = $collection->first(defaultValueIfNotFound: 'fallback');
+
+ /** @Then it should return the default value */
+ self::assertSame('fallback', $actual);
+ }
+
+ public function testFirstReturnsNullWhenEmpty(): void
+ {
+ /** @Given an empty lazy collection */
+ $collection = Collection::createLazyFromEmpty();
+
+ /** @When retrieving the first element without a default */
+ $actual = $collection->first();
+
+ /** @Then it should return null */
+ self::assertNull($actual);
+ }
+
+ public function testFirstReturnsNullElementInsteadOfDefault(): void
+ {
+ /** @Given a lazy collection where the first element is null */
+ $collection = Collection::createLazyFrom(elements: [null, 1, 2]);
+
+ /** @When retrieving the first element with a default */
+ $actual = $collection->first(defaultValueIfNotFound: 'fallback');
+
+ /** @Then it should return null, not the default */
+ self::assertNull($actual);
+ }
+
+ public function testFlatten(): void
+ {
+ /** @Given a lazy collection with nested arrays */
+ $collection = Collection::createLazyFrom(elements: [[1, 2], [3, 4], 5]);
+
+ /** @When flattening by one level */
+ $actual = $collection->flatten();
+
+ /** @Then all elements should be at the top level */
+ self::assertSame([1, 2, 3, 4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testGetByIndex(): void
+ {
+ /** @Given a lazy collection with three elements */
+ $collection = Collection::createLazyFrom(elements: ['a', 'b', 'c']);
+
+ /** @When retrieving the element at index 1 */
+ $actual = $collection->getBy(index: 1);
+
+ /** @Then it should return 'b' */
+ self::assertSame('b', $actual);
+ }
+
+ public function testGetByIndexReturnsDefaultWhenOutOfBounds(): void
+ {
+ /** @Given a lazy collection with three elements */
+ $collection = Collection::createLazyFrom(elements: ['a', 'b', 'c']);
+
+ /** @When retrieving an element at an index that does not exist */
+ $actual = $collection->getBy(index: 99, defaultValueIfNotFound: 'missing');
+
+ /** @Then it should return the default value */
+ self::assertSame('missing', $actual);
+ }
+
+ public function testGroupBy(): void
+ {
+ /** @Given a lazy collection of integers from 1 to 6 */
+ $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5, 6]);
+
+ /** @When grouping by even and odd */
+ $actual = $collection->groupBy(
+ classifier: static fn(int $value): string => $value % 2 === 0 ? 'even' : 'odd'
+ );
+
+ /** @Then the odd group should contain 1, 3, 5 */
+ $groups = $actual->toArray();
+ self::assertSame([1, 3, 5], $groups['odd']);
+
+ /** @And the even group should contain 2, 4, 6 */
+ self::assertSame([2, 4, 6], $groups['even']);
+ }
+
+ public function testIsEmpty(): void
+ {
+ /** @Given an empty lazy collection */
+ $empty = Collection::createLazyFromEmpty();
+
+ /** @Then the empty collection should return true */
+ self::assertTrue($empty->isEmpty());
+ }
+
+ public function testIsNotEmpty(): void
+ {
+ /** @Given a non-empty lazy collection */
+ $nonEmpty = Collection::createLazyFrom(elements: [1]);
+
+ /** @Then the non-empty collection should return false */
+ self::assertFalse($nonEmpty->isEmpty());
+ }
+
+ public function testJoinToString(): void
+ {
+ /** @Given a lazy collection of strings */
+ $collection = Collection::createLazyFrom(elements: ['a', 'b', 'c']);
+
+ /** @When joining with a comma separator */
+ $actual = $collection->joinToString(separator: ', ');
+
+ /** @Then the result should be "a, b, c" */
+ self::assertSame('a, b, c', $actual);
+ }
+
+ public function testJoinToStringWithIntegers(): void
+ {
+ /** @Given a lazy collection of integers */
+ $collection = Collection::createLazyFrom(elements: [1, 2, 3]);
+
+ /** @When joining with a comma separator */
+ $actual = $collection->joinToString(separator: ', ');
+
+ /** @Then the result should be "1, 2, 3" */
+ self::assertSame('1, 2, 3', $actual);
+ }
+
+ public function testJoinToStringWithSingleInteger(): void
+ {
+ /** @Given a lazy collection with a single integer */
+ $collection = Collection::createLazyFrom(elements: [42]);
+
+ /** @When joining to string */
+ $actual = $collection->joinToString(separator: ', ');
+
+ /** @Then the result should be a string */
+ self::assertSame('42', $actual);
+ }
+
+ public function testFilterWithPredicate(): void
+ {
+ /** @Given a lazy collection of integers */
+ $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5]);
+
+ /** @When keeping only elements greater than 3 */
+ $actual = $collection->filter(predicates: static fn(int $value): bool => $value > 3);
+
+ /** @Then only 4 and 5 should remain */
+ self::assertSame([4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testFilterWithoutPredicateRemovesFalsyValues(): void
+ {
+ /** @Given a lazy collection with falsy and truthy values */
+ $collection = Collection::createLazyFrom(elements: [0, '', null, false, 1, 'hello', 2]);
+
+ /** @When filtering without a predicate */
+ $actual = $collection->filter();
+
+ /** @Then only truthy values should remain */
+ self::assertSame([1, 'hello', 2], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testFilterWithExplicitNull(): void
+ {
+ /** @Given a lazy collection with falsy and truthy values */
+ $collection = Collection::createLazyFrom(elements: [0, '', 1, 'hello', 2]);
+
+ /** @When filtering with an explicit null predicate */
+ $actual = $collection->filter(null);
+
+ /** @Then only truthy values should remain */
+ self::assertSame([1, 'hello', 2], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testFilterPreservesKeys(): void
+ {
+ /** @Given a lazy collection with string keys */
+ $collection = Collection::createLazyFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]);
+
+ /** @When filtering only elements greater than 1 */
+ $actual = $collection->filter(predicates: static fn(int $value): bool => $value > 1);
+
+ /** @Then the remaining keys should be preserved */
+ self::assertSame(['b' => 2, 'c' => 3], $actual->toArray());
+ }
+
+ public function testLastReturnsElement(): void
+ {
+ /** @Given a lazy collection with three elements */
+ $collection = Collection::createLazyFrom(elements: [10, 20, 30]);
+
+ /** @When retrieving the last element */
+ $actual = $collection->last();
+
+ /** @Then it should return 30 */
+ self::assertSame(30, $actual);
+ }
+
+ public function testLastReturnsDefaultWhenEmpty(): void
+ {
+ /** @Given an empty lazy collection */
+ $collection = Collection::createLazyFromEmpty();
+
+ /** @When retrieving the last element with a default */
+ $actual = $collection->last(defaultValueIfNotFound: 'fallback');
+
+ /** @Then it should return the default value */
+ self::assertSame('fallback', $actual);
+ }
+
+ public function testLastReturnsNullElementInsteadOfDefault(): void
+ {
+ /** @Given a lazy collection where the last element is null */
+ $collection = Collection::createLazyFrom(elements: [1, 2, null]);
+
+ /** @When retrieving the last element with a default */
+ $actual = $collection->last(defaultValueIfNotFound: 'fallback');
+
+ /** @Then it should return null, not the default */
+ self::assertNull($actual);
+ }
+
+ public function testMap(): void
+ {
+ /** @Given a lazy collection of integers */
+ $collection = Collection::createLazyFrom(elements: [1, 2, 3]);
+
+ /** @When transforming each element by multiplying by 10 */
+ $actual = $collection->map(transformations: static fn(int $value): int => $value * 10);
+
+ /** @Then each element should be multiplied */
+ self::assertSame([10, 20, 30], $actual->toArray());
+ }
+
+ public function testMapPreservesKeys(): void
+ {
+ /** @Given a lazy collection with string keys */
+ $collection = Collection::createLazyFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]);
+
+ /** @When transforming each element */
+ $actual = $collection->map(transformations: static fn(int $value): int => $value * 10);
+
+ /** @Then the keys should be preserved */
+ self::assertSame(['a' => 10, 'b' => 20, 'c' => 30], $actual->toArray());
+ }
+
+ public function testReduce(): void
+ {
+ /** @Given a lazy collection of integers */
+ $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4]);
+
+ /** @When reducing to calculate the sum */
+ $actual = $collection->reduce(
+ accumulator: static fn(int $carry, int $value): int => $carry + $value,
+ initial: 0
+ );
+
+ /** @Then the sum should be 10 */
+ self::assertSame(10, $actual);
+ }
+
+ public function testSortAscending(): void
+ {
+ /** @Given a lazy collection with unordered elements */
+ $collection = Collection::createLazyFrom(elements: [3, 1, 2]);
+
+ /** @When sorting in ascending order by value */
+ $actual = $collection->sort(order: Order::ASCENDING_VALUE);
+
+ /** @Then the elements should be in ascending order */
+ self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testSortDescending(): void
+ {
+ /** @Given a lazy collection with ordered elements */
+ $collection = Collection::createLazyFrom(elements: [1, 2, 3]);
+
+ /** @When sorting in descending order by value */
+ $actual = $collection->sort(order: Order::DESCENDING_VALUE);
+
+ /** @Then the elements should be in descending order */
+ self::assertSame([3, 2, 1], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testSortAscendingKey(): void
+ {
+ /** @Given a lazy collection with unordered string keys */
+ $collection = Collection::createLazyFrom(elements: ['c' => 3, 'a' => 1, 'b' => 2]);
+
+ /** @When sorting by ascending key */
+ $actual = $collection->sort();
+
+ /** @Then the keys should be in ascending order */
+ self::assertSame(['a' => 1, 'b' => 2, 'c' => 3], $actual->toArray());
+ }
+
+ public function testSortDescendingKey(): void
+ {
+ /** @Given a lazy collection with ordered string keys */
+ $collection = Collection::createLazyFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]);
+
+ /** @When sorting by descending key */
+ $actual = $collection->sort(order: Order::DESCENDING_KEY);
+
+ /** @Then the keys should be in descending order */
+ self::assertSame(['c' => 3, 'b' => 2, 'a' => 1], $actual->toArray());
+ }
+
+ public function testSortAscendingValueWithoutComparator(): void
+ {
+ /** @Given a lazy collection with unordered integers */
+ $collection = Collection::createLazyFrom(elements: [3, 1, 4, 1, 5]);
+
+ /** @When sorting ascending by value without a custom comparator */
+ $actual = $collection->sort(order: Order::ASCENDING_VALUE);
+
+ /** @Then the elements should be sorted by the default spaceship operator */
+ self::assertSame([1, 1, 3, 4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testSortWithCustomComparator(): void
+ {
+ /** @Given a lazy collection of Amount objects */
+ $collection = Collection::createLazyFrom(elements: [
+ new Amount(value: 300.00, currency: Currency::USD),
+ new Amount(value: 100.00, currency: Currency::USD),
+ new Amount(value: 200.00, currency: Currency::USD)
+ ]);
+
+ /** @When sorting ascending by value with a custom comparator */
+ $actual = $collection->sort(
+ order: Order::ASCENDING_VALUE,
+ comparator: static fn(Amount $first, Amount $second): int => $first->value <=> $second->value
+ );
+
+ /** @Then the first element should have the lowest value */
+ self::assertSame(100.00, $actual->first()->value);
+
+ /** @And the last element should have the highest value */
+ self::assertSame(300.00, $actual->last()->value);
+ }
+
+ public function testSortWithCustomComparatorProducesDifferentOrderThanDefault(): void
+ {
+ /** @Given a lazy collection where alphabetical and length order diverge */
+ $collection = Collection::createLazyFrom(elements: ['zz', 'a', 'bbb']);
+
+ /** @When sorting ascending by length */
+ $byLength = $collection->sort(
+ order: Order::ASCENDING_VALUE,
+ comparator: static fn(string $first, string $second): int => strlen($first) <=> strlen($second)
+ );
+
+ /** @And sorting ascending by default (alphabetical) */
+ $byDefault = $collection->sort(order: Order::ASCENDING_VALUE);
+
+ /** @Then the custom order should be by length */
+ self::assertSame(['a', 'zz', 'bbb'], $byLength->toArray(keyPreservation: KeyPreservation::DISCARD));
+
+ /** @And the default order should be alphabetical */
+ self::assertSame(['a', 'bbb', 'zz'], $byDefault->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testSlice(): void
+ {
+ /** @Given a lazy collection of five elements */
+ $collection = Collection::createLazyFrom(elements: [10, 20, 30, 40, 50]);
+
+ /** @When slicing from offset 1 with length 2 */
+ $actual = $collection->slice(offset: 1, length: 2);
+
+ /** @Then the result should contain elements 20 and 30 */
+ self::assertSame([20, 30], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testSliceUntilEnd(): void
+ {
+ /** @Given a lazy collection of five elements */
+ $collection = Collection::createLazyFrom(elements: [10, 20, 30, 40, 50]);
+
+ /** @When slicing from offset 2 without specifying length */
+ $actual = $collection->slice(offset: 2);
+
+ /** @Then the result should contain all elements from index 2 onward */
+ self::assertSame([30, 40, 50], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testSlicePreservesKeys(): void
+ {
+ /** @Given a lazy collection with string keys */
+ $collection = Collection::createLazyFrom(elements: ['a' => 10, 'b' => 20, 'c' => 30, 'd' => 40]);
+
+ /** @When slicing from offset 1 with length 2 */
+ $actual = $collection->slice(offset: 1, length: 2);
+
+ /** @Then the keys should be preserved */
+ self::assertSame(['b' => 20, 'c' => 30], $actual->toArray());
+ }
+
+ public function testSliceWithZeroLengthReturnsEmpty(): void
+ {
+ /** @Given a lazy collection with five elements */
+ $collection = Collection::createLazyFrom(elements: [10, 20, 30, 40, 50]);
+
+ /** @When slicing with length zero */
+ $actual = $collection->slice(offset: 0, length: 0);
+
+ /** @Then the result should be empty */
+ self::assertTrue($actual->isEmpty());
+
+ /** @And the count should be zero */
+ self::assertSame(0, $actual->count());
+ }
+
+ public function testSliceWithNegativeLengthExcludesTrailingElements(): void
+ {
+ /** @Given a lazy collection with five elements */
+ $collection = Collection::createLazyFrom(elements: [10, 20, 30, 40, 50]);
+
+ /** @When slicing from offset 0 with length -2 (exclude last 2) */
+ $actual = $collection->slice(offset: 0, length: -2);
+
+ /** @Then the result should contain the first three elements */
+ self::assertSame([10, 20, 30], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testSliceWithOffsetAndNegativeLength(): void
+ {
+ /** @Given a lazy collection with five elements */
+ $collection = Collection::createLazyFrom(elements: [10, 20, 30, 40, 50]);
+
+ /** @When slicing from offset 1 with length -2 (skip first, exclude last 2) */
+ $actual = $collection->slice(offset: 1, length: -2);
+
+ /** @Then the result should contain elements 20 and 30 */
+ self::assertSame([20, 30], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testSliceWithNegativeLengthPreservesKeys(): void
+ {
+ /** @Given a lazy collection with string keys */
+ $collection = Collection::createLazyFrom(elements: ['a' => 10, 'b' => 20, 'c' => 30, 'd' => 40]);
+
+ /** @When slicing from offset 0 with length -2 */
+ $actual = $collection->slice(offset: 0, length: -2);
+
+ /** @Then the keys should be preserved */
+ self::assertSame(['a' => 10, 'b' => 20], $actual->toArray());
+ }
+
+ public function testSliceWithNegativeLengthProducesExactCount(): void
+ {
+ /** @Given a lazy collection with six elements */
+ $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5, 6]);
+
+ /** @When slicing from offset 0 with length -3 (exclude last 3) */
+ $actual = $collection->slice(offset: 0, length: -3);
+
+ /** @Then the collection should contain exactly 3 elements */
+ self::assertCount(3, $actual);
+
+ /** @And the elements should be 1, 2, 3 */
+ self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD));
+ }
+
+ public function testToArrayPreservingKeys(): void
+ {
+ /** @Given a lazy collection with non-sequential keys */
+ $collection = Collection::createLazyFrom(elements: [0 => 'a', 2 => 'b', 5 => 'c']);
+
+ /** @When converting to array preserving keys */
+ $actual = $collection->toArray();
+
+ /** @Then the keys should be preserved */
+ self::assertSame([0 => 'a', 2 => 'b', 5 => 'c'], $actual);
+ }
+
+ public function testToArrayDiscardingKeys(): void
+ {
+ /** @Given a lazy collection with non-sequential keys */
+ $collection = Collection::createLazyFrom(elements: [0 => 'a', 2 => 'b', 5 => 'c']);
+
+ /** @When converting to array discarding keys */
+ $actual = $collection->toArray(keyPreservation: KeyPreservation::DISCARD);
+
+ /** @Then the keys should be re-indexed from 0 */
+ self::assertSame(['a', 'b', 'c'], $actual);
+ }
+
+ public function testToJson(): void
+ {
+ /** @Given a lazy collection of integers */
+ $collection = Collection::createLazyFrom(elements: [1, 2, 3]);
+
+ /** @When converting to JSON */
+ $actual = $collection->toJson();
+
+ /** @Then the result should be a valid JSON array */
+ self::assertSame('[1,2,3]', $actual);
+ }
+
+ public function testToJsonDiscardingKeys(): void
+ {
+ /** @Given a lazy collection with string keys */
+ $collection = Collection::createLazyFrom(elements: ['x' => 1, 'y' => 2]);
+
+ /** @When converting to JSON discarding keys */
+ $actual = $collection->toJson(keyPreservation: KeyPreservation::DISCARD);
+
+ /** @Then the result should be a sequential JSON array */
+ self::assertSame('[1,2]', $actual);
+ }
+
+ public function testToJsonPreservesKeysByDefault(): void
+ {
+ /** @Given a lazy collection with string keys */
+ $collection = Collection::createLazyFrom(elements: ['x' => 1, 'y' => 2]);
+
+ /** @When converting to JSON without arguments */
+ $actual = $collection->toJson();
+
+ /** @Then the result should preserve keys as a JSON object */
+ self::assertSame('{"x":1,"y":2}', $actual);
+ }
+
+ public function testImmutability(): void
+ {
+ /** @Given a lazy collection with three elements */
+ $original = Collection::createLazyFrom(elements: [1, 2, 3]);
+
+ /** @When adding a new element */
+ $modified = $original->add(4);
+
+ /** @Then the original collection should remain unchanged */
+ self::assertSame(3, $original->count());
+
+ /** @And the new collection should have four elements */
+ self::assertSame(4, $modified->count());
+ }
+
+ public function testChainedOperationsWithObjects(): void
+ {
+ /** @Given a lazy collection of Amount objects */
+ $collection = Collection::createLazyFrom(elements: [
+ new Amount(value: 50.00, currency: Currency::USD),
+ new Amount(value: 100.00, currency: Currency::USD),
+ new Amount(value: 150.00, currency: Currency::USD),
+ new Amount(value: 250.00, currency: Currency::USD),
+ new Amount(value: 500.00, currency: Currency::USD)
+ ]);
+
+ /** @And a variable to accumulate the total discounted value */
+ $totalDiscounted = 0.0;
+
+ /** @When chaining filter, map, removeAll, sort and each */
+ $actual = $collection
+ ->filter(predicates: static fn(Amount $amount): bool => $amount->value >= 100)
+ ->map(transformations: static fn(Amount $amount): Amount => new Amount(
+ value: $amount->value * 0.9,
+ currency: $amount->currency
+ ))
+ ->removeAll(predicate: static fn(Amount $amount): bool => $amount->value > 300)
+ ->sort(
+ order: Order::ASCENDING_VALUE,
+ comparator: static fn(Amount $first, Amount $second): int => $first->value <=> $second->value
+ )
+ ->each(actions: function (Amount $amount) use (&$totalDiscounted): void {
+ $totalDiscounted += $amount->value;
+ });
+
+ /** @Then the final collection should contain exactly three elements */
+ self::assertCount(3, $actual);
+
+ /** @And the total discounted value should be 450 */
+ self::assertSame(450.00, $totalDiscounted);
+
+ /** @And the first Amount should be 90 after the discount */
+ self::assertSame(90.00, $actual->first()->value);
+
+ /** @And the last Amount should be 225 after the discount */
+ self::assertSame(225.00, $actual->last()->value);
+ }
+
+ public function testChainedOperationsWithIntegers(): void
+ {
+ /** @Given a lazy collection of integers from 1 to 100 */
+ $collection = Collection::createLazyFrom(elements: range(1, 100));
+
+ /** @When keeping even numbers, squaring them, and sorting in descending order */
+ $actual = $collection
+ ->filter(predicates: static fn(int $value): bool => $value % 2 === 0)
+ ->map(transformations: static fn(int $value): int => $value ** 2)
+ ->sort(order: Order::DESCENDING_VALUE);
+
+ /** @Then the first element should be 10000 (square of 100) */
+ self::assertSame(10000, $actual->first());
+
+ /** @And the last element should be 4 (square of 2) */
+ self::assertSame(4, $actual->last());
+
+ /** @When reducing to calculate the sum of all squared even numbers */
+ $sum = $actual->reduce(
+ accumulator: static fn(int $carry, int $value): int => $carry + $value,
+ initial: 0
+ );
+
+ /** @Then the sum should be 171700 */
+ self::assertSame(171700, $sum);
+ }
+}
diff --git a/tests/Models/Carriers.php b/tests/Models/Carriers.php
new file mode 100644
index 0000000..4ec437e
--- /dev/null
+++ b/tests/Models/Carriers.php
@@ -0,0 +1,11 @@
+filter(predicates: static fn(InvoiceSummary $summary): bool => $summary->customer === $customer)
->reduce(
- aggregator: static fn(float $carry, InvoiceSummary $summary): float => $carry + $summary->amount,
+ accumulator: static fn(float $carry, InvoiceSummary $summary): float => $carry + $summary->amount,
initial: 0.0
);
}
diff --git a/tests/Models/Invoices.php b/tests/Models/Invoices.php
index 40a7319..dac4c10 100644
--- a/tests/Models/Invoices.php
+++ b/tests/Models/Invoices.php
@@ -8,4 +8,18 @@
final class Invoices extends Collection
{
+ public function totalAmount(): float
+ {
+ return $this->reduce(
+ accumulator: static fn(float $carry, Invoice $invoice): float => $carry + $invoice->amount,
+ initial: 0.0
+ );
+ }
+
+ public function forCustomer(string $customer): static
+ {
+ return $this->filter(
+ predicates: static fn(Invoice $invoice): bool => $invoice->customer === $customer
+ );
+ }
}