diff --git a/composer.json b/composer.json index 492e40c..5741231 100644 --- a/composer.json +++ b/composer.json @@ -30,10 +30,10 @@ "tiny-blocks/mapper": "^3.2" }, "require-dev": { - "ergebnis/composer-normalize": "^2.52", - "infection/infection": "^0.34", - "phpstan/phpstan": "^2.2", - "phpunit/phpunit": "^13.2", + "ergebnis/composer-normalize": "^2.54", + "infection/infection": "^0.35", + "phpstan/phpstan": "^2.3", + "phpunit/phpunit": "^13.4", "slevomat/coding-standard": "^8.31", "squizlabs/php_codesniffer": "^4.0", "symfony/console": "^8.1", @@ -76,7 +76,7 @@ "test-file": "@php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", "tests": [ "@php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", - "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html --coverage=reports/coverage" + "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html" ] } } diff --git a/src/Collectible.php b/src/Collectible.php index a7fa560..2f1b9c1 100644 --- a/src/Collectible.php +++ b/src/Collectible.php @@ -53,6 +53,18 @@ interface Collectible extends Countable, IteratorAggregate, IterableMappable */ public static function createFrom(iterable $elements): static; + /** + * Creates a collection populated with the given elements using lazy evaluation. + * + * Elements are processed on-demand through generators, consuming memory only as each element is yielded. + * + * O(1) time, O(1) space. Stores a reference to the iterable without iterating. + * + * @param iterable $elements The elements to populate the collection with. + * @return static A new collection containing the given elements. + */ + public static function createLazyFrom(iterable $elements): static; + /** * Creates an empty collection using eager evaluation. * @@ -75,18 +87,6 @@ public static function createFromEmpty(): static; */ public static function createFromClosure(Closure $factory): static; - /** - * Creates a collection populated with the given elements using lazy evaluation. - * - * Elements are processed on-demand through generators, consuming memory only as each element is yielded. - * - * O(1) time, O(1) space. Stores a reference to the iterable without iterating. - * - * @param iterable $elements The elements to populate the collection with. - * @return static A new collection containing the given elements. - */ - public static function createLazyFrom(iterable $elements): static; - /** * Creates an empty collection using lazy evaluation. * @@ -121,56 +121,17 @@ public static function createLazyFromClosure(Closure $factory): static; public function add(mixed ...$elements): static; /** - * Merges the elements of another Collectible into the current Collection. - * - * Call site: O(1) time, O(1) space. Appends a pipeline stage in both eager and lazy modes. - * Pass contribution: O(m) time, O(m) space (m = number of elements in `other`). - * - * @param Collectible $other The collection to merge with. - * @return static A new collection containing elements from both collections. - */ - public function merge(Collectible $other): static; - - /** - * Determines whether the collection contains the specified element. - * - * Uses strict equality for scalars and loose equality for objects. - * - * Eager: O(P + n) on first terminal call (triggers fused pass and scans the result). - * O(n) on subsequent calls (scans the cached result). Short-circuits when found. - * O(n) cached space. - * Lazy: O(P) per call. The search is interleaved with the pass and short-circuits when found. - * O(1) intermediate space. - * - * @param TValue $element The element to search for. - * @return bool True if the element exists, false otherwise. - */ - public function contains(mixed $element): bool; - - /** - * Returns the total number of elements. - * - * Eager: amortized O(P) on first terminal call. O(1) on subsequent calls (cached). - * O(n) cached space. - * Lazy: O(P) per call (must reach the end of the pipeline). O(1) intermediate space. + * Applies one or more transformation functions to each element. * - * @return int The element count. - */ - public function count(): int; - - /** - * Finds the first element that satisfies any given predicate. - * Without predicates, returns null. + * Transformations are applied in order. Each receives the current value and key. * - * Eager: O(P + n * p) on first terminal call (triggers fused pass and scans the result). - * O(n * p) on subsequent calls. Short-circuits when found. p = number of predicates. - * O(n) cached space. - * Lazy: O(P + p) per emitted element. Short-circuits when found. O(1) intermediate space. + * Call site: O(1) time, O(1) space. Appends a pipeline stage in both eager and lazy modes. + * Pass contribution: O(n * t) time, O(1) space (t = number of transformations). * - * @param Closure(TValue): bool ...$predicates Conditions to test each element against. - * @return TValue|null The first matching element or null if no match is found. + * @param Closure(TValue): mixed ...$transformations Functions applied to each element. + * @return static A new collection with the transformed elements. */ - public function findBy(Closure ...$predicates): mixed; + public function map(Closure ...$transformations): static; /** * Executes side effect actions on every element without modifying the collection. @@ -186,57 +147,43 @@ public function findBy(Closure ...$predicates): mixed; public function each(Closure ...$actions): void; /** - * Compares this collection with another for element-wise equality. - * - * Two collections are equal when they have the same size and every pair at the same position - * satisfies the equality comparison. + * Returns the last element, or a default if the collection is empty. * - * Eager: O(P + n) on first terminal call. O(n) on subsequent calls (over cached result). - * Short-circuits at the first mismatch. O(n) cached space. - * Lazy: O(P + n) per call. Short-circuits at the first mismatch. O(1) intermediate space. + * Eager: amortized O(P) on first terminal call. O(1) on subsequent calls (cached). + * O(n) cached space. + * Lazy: O(P) per call. Must reach the end of the pipeline. O(1) intermediate space. * - * @param Collectible $other The collection to compare against. - * @return bool True if both collections are element-wise equal. + * @template TDefault + * @param TDefault $defaultValueIfNotFound Value returned when the collection is empty. + * @return TValue|TDefault The last element or the default. */ - public function equals(Collectible $other): bool; + public function last(mixed $defaultValueIfNotFound = null): mixed; /** - * Returns a new collection with the specified element removed. - * - * All occurrences of the element are removed. - * - * Call site: O(1) time, O(1) space. Appends a pipeline stage in both eager and lazy modes. - * Pass contribution: O(n) time, O(1) space. + * Returns a new collection sorted by the given order and optional comparator. * - * @param TValue $element The element to remove. - * @return static A new collection without the specified element. - */ - public function remove(mixed $element): static; - - /** - * 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. + * Without a comparator, the spaceship operator is used. * * Call site: O(1) time, O(1) space. Appends a pipeline stage in both eager and lazy modes. - * Pass contribution: O(n) time, O(1) space. + * Pass contribution: O(n log n) time, O(n) space. Buffers all elements before emitting any output. + * breaks streaming for any stage placed after `sort` in the same pipeline. * - * @param (Closure(TValue): bool)|null $predicate Condition to determine which elements to remove. - * @return static A new collection with the matching elements removed. + * @param Order $order The sorting direction. + * @param (Closure(TValue, TValue): int)|null $comparator Custom comparison function. + * @return static A new sorted collection. */ - public function removeAll(?Closure $predicate = null): static; + public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $comparator = null): static; /** - * Retains only elements satisfying all given predicates. - * - * Without predicates, falsy values are removed. + * Returns the total number of elements. * - * Call site: O(1) time, O(1) space. Appends a pipeline stage in both eager and lazy modes. - * Pass contribution: O(n * p) time, O(1) space (p = number of predicates). + * Eager: amortized O(P) on first terminal call. O(1) on subsequent calls (cached). + * O(n) cached space. + * Lazy: O(P) per call (must reach the end of the pipeline). O(1) intermediate space. * - * @param (Closure(TValue): bool)|null ...$predicates Conditions each element must meet. - * @return static A new collection with only the matching elements. + * @return int The element count. */ - public function filter(?Closure ...$predicates): static; + public function count(): int; /** * Returns the first element, or a default if the collection is empty. @@ -251,16 +198,6 @@ public function filter(?Closure ...$predicates): static; */ public function first(mixed $defaultValueIfNotFound = null): mixed; - /** - * Flattens nested iterables by exactly one level. Non-iterable elements are yielded as-is. - * - * Call site: O(1) time, O(1) space. Appends a pipeline stage in both eager and lazy modes. - * Pass contribution: O(n + s) time, O(1) space (s = total nested elements across all iterables). - * - * @return static A new collection with elements flattened by one level. - */ - public function flatten(): static; - /** * Returns the element at the given zero-based index. * @@ -276,67 +213,71 @@ public function flatten(): static; public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed; /** - * Groups elements by a key derived from each element. - * - * The classifier receives each element and must return the group key. The resulting collection - * contains key-to-element-list pairs. + * Merges the elements of another Collectible into the current Collection. * * Call site: O(1) time, O(1) space. Appends a pipeline stage in both eager and lazy modes. - * Pass contribution: O(n) time, O(n) space. Buffers all groups before emitting. Breaks streaming. + * Pass contribution: O(m) time, O(m) space (m = number of elements in `other`). * - * @param Closure(TValue): array-key $classifier Maps each element to its group key. - * @return static A new collection of grouped elements. + * @param Collectible $other The collection to merge with. + * @return static A new collection containing elements from both collections. */ - public function groupBy(Closure $classifier): static; + public function merge(Collectible $other): static; /** - * Determines whether the collection has no elements. + * Extracts a contiguous segment of the collection. * - * Eager: amortized O(P) on first terminal call. O(1) on subsequent calls (cached). - * O(n) cached space. - * Lazy: O(P_first) per call. Short-circuits at the first emitted element. O(1) intermediate space. + * Call site: O(1) time, O(1) space. Appends a pipeline stage in both eager and lazy modes. + * Pass contribution: O(min(offset + length, n)) time, O(1) space. Iteration short-circuits once + * the segment is fully emitted. An early `slice(0, k)` against a generator source can avoid + * touching the rest. * - * @return bool True if the collection is empty. + * @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 isEmpty(): bool; + public function slice(int $offset, int $length = -1): static; /** - * Joins all elements into a string with the given separator. + * Compares this collection with another for element-wise equality. + * + * Two collections are equal when they have the same size and every pair at the same position + * satisfies the equality comparison. * * Eager: O(P + n) on first terminal call. O(n) on subsequent calls (over cached result). - * O(n) cached space plus O(n) for the resulting string. - * Lazy: O(P + n) per call. O(n) for the resulting string. + * Short-circuits at the first mismatch. O(n) cached space. + * Lazy: O(P + n) per call. Short-circuits at the first mismatch. O(1) intermediate space. * - * @param string $separator The delimiter placed between each element. - * @return string The concatenated result. + * @param Collectible $other The collection to compare against. + * @return bool True if both collections are element-wise equal. */ - public function joinToString(string $separator): string; + public function equals(Collectible $other): bool; /** - * Returns the last element, or a default if the collection is empty. + * Retains only elements satisfying all given predicates. * - * Eager: amortized O(P) on first terminal call. O(1) on subsequent calls (cached). - * O(n) cached space. - * Lazy: O(P) per call. Must reach the end of the pipeline. O(1) intermediate space. + * Without predicates, falsy values are removed. * - * @template TDefault - * @param TDefault $defaultValueIfNotFound Value returned when the collection is empty. - * @return TValue|TDefault The last element or the default. + * Call site: O(1) time, O(1) space. Appends a pipeline stage in both eager and lazy modes. + * Pass contribution: O(n * p) time, O(1) space (p = number of predicates). + * + * @param (Closure(TValue): bool)|null ...$predicates Conditions each element must meet. + * @return static A new collection with only the matching elements. */ - public function last(mixed $defaultValueIfNotFound = null): mixed; + public function filter(?Closure ...$predicates): static; /** - * Applies one or more transformation functions to each element. - * - * Transformations are applied in order. Each receives the current value and key. + * Finds the first element that satisfies any given predicate. + * Without predicates, returns null. * - * Call site: O(1) time, O(1) space. Appends a pipeline stage in both eager and lazy modes. - * Pass contribution: O(n * t) time, O(1) space (t = number of transformations). + * Eager: O(P + n * p) on first terminal call (triggers fused pass and scans the result). + * O(n * p) on subsequent calls. Short-circuits when found. p = number of predicates. + * O(n) cached space. + * Lazy: O(P + p) per emitted element. Short-circuits when found. O(1) intermediate space. * - * @param Closure(TValue): mixed ...$transformations Functions applied to each element. - * @return static A new collection with the transformed elements. + * @param Closure(TValue): bool ...$predicates Conditions to test each element against. + * @return TValue|null The first matching element or null if no match is found. */ - public function map(Closure ...$transformations): static; + public function findBy(Closure ...$predicates): mixed; /** * Reduces the collection to a single accumulated value. @@ -355,33 +296,70 @@ public function map(Closure ...$transformations): static; public function reduce(Closure $accumulator, mixed $initial): mixed; /** - * Returns a new collection sorted by the given order and optional comparator. + * Returns a new collection with the specified element removed. * - * Without a comparator, the spaceship operator is used. + * All occurrences of the element are removed. * * Call site: O(1) time, O(1) space. Appends a pipeline stage in both eager and lazy modes. - * Pass contribution: O(n log n) time, O(n) space. Buffers all elements before emitting any output. - * breaks streaming for any stage placed after `sort` in the same pipeline. + * Pass contribution: O(n) time, O(1) space. * - * @param Order $order The sorting direction. - * @param (Closure(TValue, TValue): int)|null $comparator Custom comparison function. - * @return static A new sorted collection. + * @param TValue $element The element to remove. + * @return static A new collection without the specified element. */ - public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $comparator = null): static; + public function remove(mixed $element): static; /** - * Extracts a contiguous segment of the collection. + * Converts the Collection to a JSON string. + * + * Eager: O(P + n) on first terminal call. O(n) on subsequent calls (over cached result). + * O(n) cached space plus O(n) for the JSON string. + * Lazy: O(P + n) per call. O(n) for the JSON string. + * + * The key preservation behavior should be provided from the `KeyPreservation` enum: + * - {@see KeyPreservation::PRESERVE}: Preserves the array keys. + * - {@see KeyPreservation::DISCARD}: Discards the array keys. + * + * By default, `KeyPreservation::PRESERVE` is used. + * + * @param KeyPreservation $keyPreservation The option to preserve or discard array keys. + * @return string The resulting JSON string. + */ + public function toJson(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): string; + + /** + * Flattens nested iterables by exactly one level. Non-iterable elements are yielded as-is. * * Call site: O(1) time, O(1) space. Appends a pipeline stage in both eager and lazy modes. - * Pass contribution: O(min(offset + length, n)) time, O(1) space. Iteration short-circuits once - * the segment is fully emitted. An early `slice(0, k)` against a generator source can avoid - * touching the rest. + * Pass contribution: O(n + s) time, O(1) space (s = total nested elements across all iterables). * - * @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. + * @return static A new collection with elements flattened by one level. */ - public function slice(int $offset, int $length = -1): static; + public function flatten(): static; + + /** + * Groups elements by a key derived from each element. + * + * The classifier receives each element and must return the group key. The resulting collection + * contains key-to-element-list pairs. + * + * Call site: O(1) time, O(1) space. Appends a pipeline stage in both eager and lazy modes. + * Pass contribution: O(n) time, O(n) space. Buffers all groups before emitting. Breaks streaming. + * + * @param Closure(TValue): array-key $classifier Maps each element to its group key. + * @return static A new collection of grouped elements. + */ + public function groupBy(Closure $classifier): static; + + /** + * Determines whether the collection has no elements. + * + * Eager: amortized O(P) on first terminal call. O(1) on subsequent calls (cached). + * O(n) cached space. + * Lazy: O(P_first) per call. Short-circuits at the first emitted element. O(1) intermediate space. + * + * @return bool True if the collection is empty. + */ + public function isEmpty(): bool; /** * Converts the Collection to an array. @@ -402,20 +380,42 @@ public function slice(int $offset, int $length = -1): static; public function toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): array; /** - * Converts the Collection to a JSON string. + * Determines whether the collection contains the specified element. * - * Eager: O(P + n) on first terminal call. O(n) on subsequent calls (over cached result). - * O(n) cached space plus O(n) for the JSON string. - * Lazy: O(P + n) per call. O(n) for the JSON string. + * Uses strict equality for scalars and loose equality for objects. * - * The key preservation behavior should be provided from the `KeyPreservation` enum: - * - {@see KeyPreservation::PRESERVE}: Preserves the array keys. - * - {@see KeyPreservation::DISCARD}: Discards the array keys. + * Eager: O(P + n) on first terminal call (triggers fused pass and scans the result). + * O(n) on subsequent calls (scans the cached result). Short-circuits when found. + * O(n) cached space. + * Lazy: O(P) per call. The search is interleaved with the pass and short-circuits when found. + * O(1) intermediate space. * - * By default, `KeyPreservation::PRESERVE` is used. + * @param TValue $element The element to search for. + * @return bool True if the element exists, false otherwise. + */ + public function contains(mixed $element): bool; + + /** + * 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. * - * @param KeyPreservation $keyPreservation The option to preserve or discard array keys. - * @return string The resulting JSON string. + * Call site: O(1) time, O(1) space. Appends a pipeline stage in both eager and lazy modes. + * Pass contribution: O(n) time, O(1) space. + * + * @param (Closure(TValue): bool)|null $predicate Condition to determine which elements to remove. + * @return static A new collection with the matching elements removed. */ - public function toJson(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): string; + public function removeAll(?Closure $predicate = null): static; + + /** + * Joins all elements into a string with the given separator. + * + * Eager: O(P + n) on first terminal call. O(n) on subsequent calls (over cached result). + * O(n) cached space plus O(n) for the resulting string. + * Lazy: O(P + n) per call. O(n) for the resulting string. + * + * @param string $separator The delimiter placed between each element. + * @return string The concatenated result. + */ + public function joinToString(string $separator): string; } diff --git a/src/Collection.php b/src/Collection.php index 4eacefb..28813b3 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -47,6 +47,11 @@ public static function createFrom(iterable $elements): static return new static(pipeline: EagerPipeline::from(source: $elements)); } + public static function createLazyFrom(iterable $elements): static + { + return new static(pipeline: LazyPipeline::from(source: $elements)); + } + public static function createFromEmpty(): static { return static::createFrom(elements: []); @@ -57,11 +62,6 @@ public static function createFromClosure(Closure $factory): static return new static(pipeline: EagerPipeline::fromClosure(factory: $factory)); } - public static function createLazyFrom(iterable $elements): static - { - return new static(pipeline: LazyPipeline::from(source: $elements)); - } - public static function createLazyFromEmpty(): static { return static::createLazyFrom(elements: []); @@ -72,116 +72,106 @@ public static function createLazyFromClosure(Closure $factory): static return new static(pipeline: LazyPipeline::fromClosure(factory: $factory)); } - public function getIterator(): Traversable - { - return $this->pipeline->process(); - } - public function add(mixed ...$elements): static { return new static(pipeline: $this->pipeline->pipe(operation: Add::these(newElements: $elements))); } - public function merge(Collectible $other): static + public function map(Closure ...$transformations): static { - return new static(pipeline: $this->pipeline->pipe(operation: Merge::with(other: $other))); + return new static(pipeline: $this->pipeline->pipe(operation: Map::using(...$transformations))); } - public function contains(mixed $element): bool + public function each(Closure ...$actions): void { - return Equality::exists(elements: $this->pipeline->process(), element: $element); + Each::on(actions: $actions, elements: $this->pipeline->process()); } - public function count(): int + public function last(mixed $defaultValueIfNotFound = null): mixed { - return $this->pipeline->count(); + return $this->pipeline->last(defaultValueIfNotFound: $defaultValueIfNotFound); } - public function findBy(Closure ...$predicates): mixed + public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $comparator = null): static { - return Find::firstMatch(elements: $this->pipeline->process(), predicates: $predicates); - } + $operation = Rearrange::by(order: $order, comparator: $comparator); - public function each(Closure ...$actions): void - { - Each::on(elements: $this->pipeline->process(), actions: $actions); + return new static(pipeline: $this->pipeline->pipe(operation: $operation)); } - public function equals(Collectible $other): bool + public function count(): int { - return Equality::compareAll(elements: $this->pipeline->process(), other: $other); + return $this->pipeline->count(); } - public function remove(mixed $element): static + public function first(mixed $defaultValueIfNotFound = null): mixed { - return new static(pipeline: $this->pipeline->pipe(operation: Remove::element(element: $element))); + return $this->pipeline->first(defaultValueIfNotFound: $defaultValueIfNotFound); } - public function removeAll(?Closure $predicate = null): static + public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed { - return new static(pipeline: $this->pipeline->pipe(operation: RemoveAll::matching(predicate: $predicate))); + return $this->pipeline->getBy(index: $index, defaultValueIfNotFound: $defaultValueIfNotFound); } - public function filter(?Closure ...$predicates): static + public function merge(Collectible $other): static { - return new static(pipeline: $this->pipeline->pipe(operation: Filter::matching(...$predicates))); + return new static(pipeline: $this->pipeline->pipe(operation: Merge::with(other: $other))); } - public function first(mixed $defaultValueIfNotFound = null): mixed + public function slice(int $offset, int $length = -1): static { - return $this->pipeline->first(defaultValueIfNotFound: $defaultValueIfNotFound); + return new static(pipeline: $this->pipeline->pipe(operation: Segment::from(length: $length, offset: $offset))); } - public function flatten(): static + public function equals(Collectible $other): bool { - return new static(pipeline: $this->pipeline->pipe(operation: FlatMap::oneLevel())); + return Equality::compareAll(other: $other, elements: $this->pipeline->process()); } - public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed + public function filter(?Closure ...$predicates): static { - return $this->pipeline->getBy(index: $index, defaultValueIfNotFound: $defaultValueIfNotFound); + return new static(pipeline: $this->pipeline->pipe(operation: Filter::matching(...$predicates))); } - public function groupBy(Closure $classifier): static + public function findBy(Closure ...$predicates): mixed { - return new static(pipeline: $this->pipeline->pipe(operation: GroupInto::by(classifier: $classifier))); + return Find::firstMatch(elements: $this->pipeline->process(), predicates: $predicates); } - public function isEmpty(): bool + public function reduce(Closure $accumulator, mixed $initial): mixed { - return $this->pipeline->isEmpty(); + return Reduce::from(initial: $initial, elements: $this->pipeline->process(), accumulator: $accumulator); } - public function joinToString(string $separator): string + public function remove(mixed $element): static { - return Join::elements(elements: $this->pipeline->process(), separator: $separator); + return new static(pipeline: $this->pipeline->pipe(operation: Remove::element(element: $element))); } - public function last(mixed $defaultValueIfNotFound = null): mixed + public function toJson(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): string { - return $this->pipeline->last(defaultValueIfNotFound: $defaultValueIfNotFound); - } + $configuration = match ($keyPreservation) { + KeyPreservation::DISCARD => Configuration::default()->discardingKeys(), + KeyPreservation::PRESERVE => Configuration::default() + }; - public function map(Closure ...$transformations): static - { - return new static(pipeline: $this->pipeline->pipe(operation: Map::using(...$transformations))); + return Mapper::create()->toJson(source: $this, configuration: $configuration); } - public function reduce(Closure $accumulator, mixed $initial): mixed + public function flatten(): static { - return Reduce::from(elements: $this->pipeline->process(), accumulator: $accumulator, initial: $initial); + return new static(pipeline: $this->pipeline->pipe(operation: FlatMap::oneLevel())); } - public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $comparator = null): static + public function groupBy(Closure $classifier): static { - $operation = Rearrange::by(order: $order, comparator: $comparator); - - return new static(pipeline: $this->pipeline->pipe(operation: $operation)); + return new static(pipeline: $this->pipeline->pipe(operation: GroupInto::by(classifier: $classifier))); } - public function slice(int $offset, int $length = -1): static + public function isEmpty(): bool { - return new static(pipeline: $this->pipeline->pipe(operation: Segment::from(offset: $offset, length: $length))); + return $this->pipeline->isEmpty(); } public function toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): array @@ -194,13 +184,23 @@ public function toArray(KeyPreservation $keyPreservation = KeyPreservation::PRES return Mapper::create()->toArray(source: $this, configuration: $configuration); } - public function toJson(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): string + public function contains(mixed $element): bool { - $configuration = match ($keyPreservation) { - KeyPreservation::DISCARD => Configuration::default()->discardingKeys(), - KeyPreservation::PRESERVE => Configuration::default() - }; + return Equality::exists(element: $element, elements: $this->pipeline->process()); + } - return Mapper::create()->toJson(source: $this, configuration: $configuration); + public function removeAll(?Closure $predicate = null): static + { + return new static(pipeline: $this->pipeline->pipe(operation: RemoveAll::matching(predicate: $predicate))); + } + + public function getIterator(): Traversable + { + return $this->pipeline->process(); + } + + public function joinToString(string $separator): string + { + return Join::elements(elements: $this->pipeline->process(), separator: $separator); } } diff --git a/src/Internal/EagerPipeline.php b/src/Internal/EagerPipeline.php index b4e64ce..b0454e3 100644 --- a/src/Internal/EagerPipeline.php +++ b/src/Internal/EagerPipeline.php @@ -46,6 +46,15 @@ public static function fromClosure(Closure $factory): EagerPipeline return new EagerPipeline(materialization: Materialization::from(source: $elements, stages: [])); } + public function last(mixed $defaultValueIfNotFound = null): mixed + { + $elements = $this->materialization->elements(); + + return $elements === [] + ? $defaultValueIfNotFound + : $elements[array_key_last($elements)]; + } + public function pipe(Operation $operation): Pipeline { return new EagerPipeline(materialization: $this->materialization->withStage(operation: $operation)); @@ -56,11 +65,6 @@ public function count(): int return count($this->materialization->elements()); } - public function isEmpty(): bool - { - return $this->materialization->elements() === []; - } - public function first(mixed $defaultValueIfNotFound = null): mixed { $elements = $this->materialization->elements(); @@ -70,15 +74,6 @@ public function first(mixed $defaultValueIfNotFound = null): mixed : $elements[array_key_first($elements)]; } - public function last(mixed $defaultValueIfNotFound = null): mixed - { - $elements = $this->materialization->elements(); - - return $elements === [] - ? $defaultValueIfNotFound - : $elements[array_key_last($elements)]; - } - public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed { $elements = $this->materialization->elements(); @@ -88,6 +83,11 @@ public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed : $defaultValueIfNotFound; } + public function isEmpty(): bool + { + return $this->materialization->elements() === []; + } + public function process(): Generator { /** @var array $elements */ diff --git a/src/Internal/LazyPipeline.php b/src/Internal/LazyPipeline.php index d3a1b04..7115cf7 100644 --- a/src/Internal/LazyPipeline.php +++ b/src/Internal/LazyPipeline.php @@ -41,6 +41,17 @@ public static function fromClosure(Closure $factory): LazyPipeline return new LazyPipeline(source: $factory); } + public function last(mixed $defaultValueIfNotFound = null): mixed + { + $last = $defaultValueIfNotFound; + + foreach ($this->process() as $element) { + $last = $element; + } + + return $last; + } + public function pipe(Operation $operation): Pipeline { $stages = $this->stages; @@ -63,22 +74,6 @@ public function first(mixed $defaultValueIfNotFound = null): mixed return $defaultValueIfNotFound; } - public function isEmpty(): bool - { - return !$this->process()->valid(); - } - - public function last(mixed $defaultValueIfNotFound = null): mixed - { - $last = $defaultValueIfNotFound; - - foreach ($this->process() as $element) { - $last = $element; - } - - return $last; - } - public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed { foreach ($this->process() as $currentIndex => $value) { @@ -90,6 +85,11 @@ public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed return $defaultValueIfNotFound; } + public function isEmpty(): bool + { + return !$this->process()->valid(); + } + public function process(): Generator { /** @var iterable $elements */ diff --git a/src/Internal/Operations/Resolving/Each.php b/src/Internal/Operations/Resolving/Each.php index d23ea2f..7bca864 100644 --- a/src/Internal/Operations/Resolving/Each.php +++ b/src/Internal/Operations/Resolving/Each.php @@ -10,7 +10,7 @@ private function __construct() { } - public static function on(iterable $elements, array $actions): void + public static function on(array $actions, iterable $elements): void { foreach ($elements as $key => $value) { foreach ($actions as $action) { diff --git a/src/Internal/Operations/Resolving/Equality.php b/src/Internal/Operations/Resolving/Equality.php index a5a10ec..3ee0ab9 100644 --- a/src/Internal/Operations/Resolving/Equality.php +++ b/src/Internal/Operations/Resolving/Equality.php @@ -13,24 +13,13 @@ private function __construct() { } - public static function areSame(mixed $element, mixed $other): bool - { - if (is_object($element) !== is_object($other)) { - return false; - } - - return is_object($element) - ? $element == $other - : $element === $other; - } - /** * @param iterable $elements */ - public static function exists(iterable $elements, mixed $element): bool + public static function exists(mixed $element, iterable $elements): bool { foreach ($elements as $current) { - if (Equality::areSame(element: $current, other: $element)) { + if (Equality::areSame(other: $element, element: $current)) { return true; } } @@ -38,14 +27,25 @@ public static function exists(iterable $elements, mixed $element): bool return false; } + public static function areSame(mixed $other, mixed $element): bool + { + if (is_object($element) !== is_object($other)) { + return false; + } + + return is_object($element) + ? $element == $other + : $element === $other; + } + /** - * @param Iterator $elements * @param Collectible $other + * @param Iterator $elements */ - public static function compareAll(Iterator $elements, Collectible $other): bool + public static function compareAll(Collectible $other, Iterator $elements): bool { foreach ($other as $value) { - if (!$elements->valid() || !Equality::areSame(element: $elements->current(), other: $value)) { + if (!$elements->valid() || !Equality::areSame(other: $value, element: $elements->current())) { return false; } diff --git a/src/Internal/Operations/Resolving/Reduce.php b/src/Internal/Operations/Resolving/Reduce.php index 8266c05..939521e 100644 --- a/src/Internal/Operations/Resolving/Reduce.php +++ b/src/Internal/Operations/Resolving/Reduce.php @@ -12,7 +12,7 @@ private function __construct() { } - public static function from(iterable $elements, Closure $accumulator, mixed $initial): mixed + public static function from(mixed $initial, iterable $elements, Closure $accumulator): mixed { $carry = $initial; diff --git a/src/Internal/Operations/Transforming/Remove.php b/src/Internal/Operations/Transforming/Remove.php index f225a2c..bb44506 100644 --- a/src/Internal/Operations/Transforming/Remove.php +++ b/src/Internal/Operations/Transforming/Remove.php @@ -22,7 +22,7 @@ public static function element(mixed $element): Remove public function apply(iterable $elements): Generator { foreach ($elements as $key => $value) { - if (!Equality::areSame(element: $this->element, other: $value)) { + if (!Equality::areSame(other: $value, element: $this->element)) { yield $key => $value; } } diff --git a/src/Internal/Operations/Transforming/Segment.php b/src/Internal/Operations/Transforming/Segment.php index a4e3a51..4e77adb 100644 --- a/src/Internal/Operations/Transforming/Segment.php +++ b/src/Internal/Operations/Transforming/Segment.php @@ -10,13 +10,13 @@ final readonly class Segment implements Operation { - private function __construct(private int $offset, private int $length) + private function __construct(private int $length, private int $offset) { } - public static function from(int $offset, int $length): Segment + public static function from(int $length, int $offset): Segment { - return new Segment(offset: $offset, length: $length); + return new Segment(length: $length, offset: $offset); } public function apply(iterable $elements): Generator diff --git a/src/Internal/Pipeline.php b/src/Internal/Pipeline.php index 3798e22..09e487e 100644 --- a/src/Internal/Pipeline.php +++ b/src/Internal/Pipeline.php @@ -25,6 +25,19 @@ */ interface Pipeline { + /** + * Returns the last element, or a default if empty. + * + * Eager: amortized O(P) on first terminal call. O(1) on subsequent calls (cached). + * O(n) cached space. + * Lazy: O(P) per call. Must reach the end of the pipeline. O(1) intermediate space. + * + * @template TDefault + * @param TDefault $defaultValueIfNotFound Value returned when empty. + * @return TValue|TDefault The last element or the default. + */ + public function last(mixed $defaultValueIfNotFound = null): mixed; + /** * Adds a new operation stage to the pipeline. * @@ -64,42 +77,29 @@ public function count(): int; public function first(mixed $defaultValueIfNotFound = null): mixed; /** - * Determines whether the pipeline has no elements. - * - * Eager: amortized O(P) on first terminal call. O(1) on subsequent calls (cached). - * O(n) cached space. - * Lazy: O(P_first) per call. Short-circuits at the first emitted element. O(1) intermediate space. - * - * @return bool True if the pipeline is empty. - */ - public function isEmpty(): bool; - - /** - * Returns the last element, or a default if empty. + * Returns the element at the given zero-based index. * * Eager: amortized O(P) on first terminal call. O(1) on subsequent calls (cached). * O(n) cached space. - * Lazy: O(P) per call. Must reach the end of the pipeline. O(1) intermediate space. + * Lazy: O(P_index) per call. Short-circuits at the requested position. O(1) intermediate space. * * @template TDefault - * @param TDefault $defaultValueIfNotFound Value returned when empty. - * @return TValue|TDefault The last element or the default. + * @param int $index The zero-based position. + * @param TDefault $defaultValueIfNotFound Value returned when the index is out of bounds. + * @return TValue|TDefault The element at the index or the default. */ - public function last(mixed $defaultValueIfNotFound = null): mixed; + public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed; /** - * Returns the element at the given zero-based index. + * Determines whether the pipeline has no elements. * * Eager: amortized O(P) on first terminal call. O(1) on subsequent calls (cached). * O(n) cached space. - * Lazy: O(P_index) per call. Short-circuits at the requested position. O(1) intermediate space. + * Lazy: O(P_first) per call. Short-circuits at the first emitted element. O(1) intermediate space. * - * @template TDefault - * @param int $index The zero-based position. - * @param TDefault $defaultValueIfNotFound Value returned when the index is out of bounds. - * @return TValue|TDefault The element at the index or the default. + * @return bool True if the pipeline is empty. */ - public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed; + public function isEmpty(): bool; /** * Executes all accumulated stages and yields the resulting elements. diff --git a/tests/CollectionTest.php b/tests/CollectionTest.php index fd54708..a511c46 100644 --- a/tests/CollectionTest.php +++ b/tests/CollectionTest.php @@ -24,198 +24,185 @@ final class CollectionTest extends TestCase { - public function testPipelineWhenIntegerElementsThenLazyAndEagerProduceIdenticalArrays(): void + public function testAddWhenTypedCarriersThenTypeIsPreserved(): void { - /** @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); + /** @Given a Carriers collection with two carrier names */ + $carriers = Carriers::createFrom(elements: ['DHL', 'FedEx']); - /** @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); + /** @When adding a new carrier */ + $actual = $carriers->add('Correios'); - /** @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 the result should still be an instance of Carriers */ + self::assertInstanceOf(Carriers::class, $actual); - /** @Then both collections should produce identical arrays */ - self::assertSame($lazyResult, $eagerResult); + /** @And it should contain three carriers */ + self::assertSame(3, $actual->count()); } - public function testPipelineWhenObjectElementsThenLazyAndEagerProduceEqualResults(): void + public function testFilterWhenTypedCarriersThenTypeIsPreserved(): 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; + /** @Given a Carriers collection with three carrier names */ + $carriers = Carriers::createFrom(elements: ['DHL', 'FedEx', 'UPS']); - /** @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 + /** @When filtering carriers that start with a letter after D */ + $actual = $carriers->filter( + predicates: static fn(string $name): bool => $name[0] >= 'E' ); - /** @And a removeAll predicate for amounts greater than 300 */ - $removeAll = static fn(Amount $amount): bool => $amount->value > 300; + /** @Then the result should still be an instance of Carriers */ + self::assertInstanceOf(Carriers::class, $actual); - /** @And a comparator that sorts by value */ - $comparator = static fn(Amount $first, Amount $second): int => ($first->value <=> $second->value); + /** @And it should contain two carriers */ + self::assertSame(2, $actual->count()); - /** @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 the carriers should be FedEx and UPS */ + self::assertSame(['FedEx', 'UPS'], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } - /** @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); + public function testMapWhenLazyTypedCarriersThenTypeIsPreserved(): void + { + /** @Given a lazy Carriers collection */ + $carriers = Carriers::createLazyFrom(elements: ['dhl', 'fedex', 'ups']); - /** @Then both should have the same count */ - self::assertSame($lazy->count(), $eager->count()); + /** @When mapping to uppercase */ + $actual = $carriers->map( + transformations: static fn(string $name): string => strtoupper($name) + ); - /** @And the same first value */ - self::assertSame($lazy->first()->value, $eager->first()->value); + /** @Then the result should still be an instance of Carriers */ + self::assertInstanceOf(Carriers::class, $actual); - /** @And the same last value */ - self::assertSame($lazy->last()->value, $eager->last()->value); + /** @And the carriers should be uppercased */ + self::assertSame(['DHL', 'FEDEX', 'UPS'], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testMergeWhenLazyReceivesEagerThenAllElementsArePresent(): void + public function testSortWhenLazyTypedInvoicesThenTypeIsPreserved(): void { - /** @Given a lazy collection */ - $lazy = Collection::createLazyFrom(elements: [1, 2]); + /** @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') + ]); - /** @And an eager collection */ - $eager = Collection::createFrom(elements: [3, 4]); + /** @When sorting by amount */ + $sorted = $invoices->sort( + order: SortOrder::DESCENDING_VALUE, + comparator: static fn(Invoice $first, Invoice $second): int => ($first->amount <=> $second->amount) + ); - /** @When concatenating the eager collection into the lazy one */ - $actual = $lazy->merge(other: $eager); + /** @Then the result should still be an instance of Invoices */ + self::assertInstanceOf(Invoices::class, $sorted); - /** @Then the resulting collection should contain all four elements */ - self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @And the first invoice should have the highest amount */ + self::assertSame(200.00, $sorted->first()->amount); } - public function testMergeWhenEagerReceivesLazyThenAllElementsArePresent(): void + public function testJoinToStringWhenMappedNamesThenNamesAreJoined(): void { - /** @Given an eager collection */ - $eager = Collection::createFrom(elements: [1, 2]); - - /** @And a lazy collection */ - $lazy = Collection::createLazyFrom(elements: [3, 4]); + /** @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 concatenating the lazy collection into the eager one */ - $actual = $eager->merge(other: $lazy); + /** @When mapping to names and joining with a comma */ + $names = $dragons + ->map(transformations: static fn(Dragon $dragon): string => $dragon->name) + ->joinToString(separator: ', '); - /** @Then the resulting collection should contain all four elements */ - self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then the result should list all dragon names */ + self::assertSame('Smaug, Viserion, Drogon', $names); } - public function testEqualsWhenSameElementsAcrossStrategiesThenComparisonIsTrue(): void + public function testSliceWhenTypedInvoicesThenSubsetPreservesType(): 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]); + /** @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 comparing lazy equals eager */ - $lazyEqualsEager = $lazy->equals(other: $eager); + /** @When slicing from offset 1 with length 3 */ + $sliced = $invoices->slice(offset: 1, length: 3); - /** @And comparing eager equals lazy */ - $eagerEqualsLazy = $eager->equals(other: $lazy); + /** @Then the result should still be an instance of Invoices */ + self::assertInstanceOf(Invoices::class, $sliced); - /** @Then the lazy-to-eager comparison should return true */ - self::assertTrue($lazyEqualsEager); + /** @And the sliced collection should have three invoices */ + self::assertSame(3, $sliced->count()); - /** @And the eager-to-lazy comparison should return true */ - self::assertTrue($eagerEqualsLazy); + /** @And the total of the sliced invoices should be 900 */ + self::assertSame(900.00, $sliced->totalAmount()); } - public function testReduceWhenSummingAcrossStrategiesThenBothYieldSameSum(): void + public function testRemoveWhenInvoiceIsPresentThenElementIsRemoved(): 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); + /** @Given a specific invoice to remove */ + $toRemove = new Invoice(id: 'INV-002', amount: 200.00, customer: 'Bob'); - /** @When reducing with a lazy collection */ - $lazySum = Collection::createLazyFrom(elements: $elements) - ->reduce(accumulator: $accumulator, initial: 0); + /** @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') + ]); - /** @And reducing with an eager collection */ - $eagerSum = Collection::createFrom(elements: $elements) - ->reduce(accumulator: $accumulator, initial: 0); + /** @When removing that invoice */ + $actual = $invoices->remove(element: $toRemove); - /** @Then the lazy sum should be 15 */ - self::assertSame(15, $lazySum); + /** @Then there should be two invoices remaining */ + self::assertSame(2, $actual->count()); - /** @And the eager sum should be 15 */ - self::assertSame(15, $eagerSum); + /** @And the total should be 400 */ + self::assertSame(400.00, $actual->totalAmount()); } - public function testFilterWhenTypedCarriersThenTypeIsPreserved(): void + public function testTotalAmountWhenMultipleInvoicesThenSumIsReturned(): 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); + /** @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') + ]); - /** @And it should contain two carriers */ - self::assertSame(2, $actual->count()); + /** @When calculating the total amount */ + $total = $invoices->totalAmount(); - /** @And the carriers should be FedEx and UPS */ - self::assertSame(['FedEx', 'UPS'], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then the total should be 450 */ + self::assertSame(450.00, $total); } - public function testAddWhenTypedCarriersThenTypeIsPreserved(): void + public function testFilterWhenTypedInvoiceSummariesThenTypeIsPreserved(): void { - /** @Given a Carriers collection with two carrier names */ - $carriers = Carriers::createFrom(elements: ['DHL', 'FedEx']); + /** @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 adding a new carrier */ - $actual = $carriers->add('Correios'); + /** @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 Carriers */ - self::assertInstanceOf(Carriers::class, $actual); + /** @Then the result should still be an instance of InvoiceSummaries */ + self::assertInstanceOf(InvoiceSummaries::class, $filtered); - /** @And it should contain three carriers */ - self::assertSame(3, $actual->count()); + /** @And it should contain one summary */ + self::assertSame(1, $filtered->count()); } - public function testMapWhenLazyTypedCarriersThenTypeIsPreserved(): void + public function testMapWhenLazyClosureBackedCarriersThenTypeIsPreserved(): void { - /** @Given a lazy Carriers collection */ - $carriers = Carriers::createLazyFrom(elements: ['dhl', 'fedex', 'ups']); + /** @Given a lazy closure-backed Carriers collection */ + $carriers = Carriers::createLazyFromClosure(factory: static function (): array { + return ['dhl', 'fedex', 'ups']; + }); /** @When mapping to uppercase */ $actual = $carriers->map( @@ -229,76 +216,58 @@ public function testMapWhenLazyTypedCarriersThenTypeIsPreserved(): void self::assertSame(['DHL', 'FEDEX', 'UPS'], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testTotalAmountWhenMultipleInvoicesThenSumIsReturned(): 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 testForCustomerWhenInvoicesForDifferentCustomersThenOnlyMatchingAreReturned(): void + public function testMergeWhenEagerReceivesLazyThenAllElementsArePresent(): 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'); + /** @Given an eager collection */ + $eager = Collection::createFrom(elements: [1, 2]); - /** @Then the result should still be an instance of Invoices */ - self::assertInstanceOf(Invoices::class, $aliceInvoices); + /** @And a lazy collection */ + $lazy = Collection::createLazyFrom(elements: [3, 4]); - /** @And Alice should have two invoices */ - self::assertSame(2, $aliceInvoices->count()); + /** @When concatenating the lazy collection into the eager one */ + $actual = $eager->merge(other: $lazy); - /** @And the total for Alice should be 250 */ - self::assertSame(250.00, $aliceInvoices->totalAmount()); + /** @Then the resulting collection should contain all four elements */ + self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testSumByCustomerWhenCustomerHasMultipleSummariesThenAmountsAreSummed(): void + public function testMergeWhenLazyReceivesEagerThenAllElementsArePresent(): 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') - ]); + /** @Given a lazy collection */ + $lazy = Collection::createLazyFrom(elements: [1, 2]); + + /** @And an eager collection */ + $eager = Collection::createFrom(elements: [3, 4]); - /** @When summing by customer Alice */ - $aliceTotal = $summaries->sumByCustomer(customer: 'Alice'); + /** @When concatenating the eager collection into the lazy one */ + $actual = $lazy->merge(other: $eager); - /** @Then Alice's total should be 250 */ - self::assertSame(250.00, $aliceTotal); + /** @Then the resulting collection should contain all four elements */ + self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testSumByCustomerWhenOtherCustomerHasMultipleSummariesThenAmountsAreSummed(): void + public function testMergeWhenTwoTypedCarriersThenTypeIsPreservedInOrder(): 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') - ]); + /** @Given a domestic carriers collection */ + $domestic = Carriers::createFrom(elements: ['Correios', 'Jadlog']); - /** @When summing by customer Bob */ - $bobTotal = $summaries->sumByCustomer(customer: 'Bob'); + /** @And an international carriers collection */ + $international = Carriers::createFrom(elements: ['DHL', 'FedEx']); - /** @Then Bob's total should be 500 */ - self::assertSame(500.00, $bobTotal); + /** @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) + ); } public function testSumByCustomerWhenCustomerIsAbsentThenZeroIsReturned(): void @@ -315,49 +284,47 @@ public function testSumByCustomerWhenCustomerIsAbsentThenZeroIsReturned(): void self::assertSame(0.0, $total); } - public function testGroupByWhenDragonsShareDescriptionsThenGroupedByDescription(): void + public function testGroupByWhenStatusEnumElementsThenGroupedByStatusName(): 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') + /** @Given a collection of Status enums */ + $statuses = Collection::createFrom(elements: [ + Status::PAID, + Status::PENDING, + Status::PAID, + Status::PAID, + Status::PENDING ]); - /** @When grouping by description */ - $grouped = $dragons->groupBy( - classifier: static fn(Dragon $dragon): string => $dragon->description + /** @When grouping by status name */ + $grouped = $statuses->groupBy( + classifier: static fn(Status $status): string => $status->name ); - /** @Then the fire group should contain three dragons */ + /** @Then the PAID group should have three entries */ $groups = $grouped->toArray(); - self::assertCount(3, $groups['fire']); + self::assertCount(3, $groups['PAID']); - /** @And the ice group should contain two dragons */ - self::assertCount(2, $groups['ice']); + /** @And the PENDING group should have two entries */ + self::assertCount(2, $groups['PENDING']); } - public function testFindByWhenDragonMatchesPredicateThenMatchingDragonIsReturned(): void + public function testMapWhenEagerClosureBackedCarriersThenTypeIsPreserved(): 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') - ]); + /** @Given an eager closure-backed Carriers collection */ + $carriers = Carriers::createFromClosure(factory: static function (): array { + return ['dhl', 'fedex', 'ups']; + }); - /** @When finding the dragon named Viserion */ - $actual = $dragons->findBy( - predicates: static fn(Dragon $dragon): bool => $dragon->name === 'Viserion' + /** @When mapping to uppercase */ + $actual = $carriers->map( + transformations: static fn(string $name): string => strtoupper($name) ); - /** @Then it should return Viserion */ - self::assertSame('Viserion', $actual->name); + /** @Then the result should still be an instance of Carriers */ + self::assertInstanceOf(Carriers::class, $actual); - /** @And Viserion should be an ice dragon */ - self::assertSame('ice', $actual->description); + /** @And the carriers should be uppercased */ + self::assertSame(['DHL', 'FEDEX', 'UPS'], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } public function testSortWhenPriceComparatorThenElementsAreOrderedByPrice(): void @@ -385,53 +352,48 @@ public function testSortWhenPriceComparatorThenElementsAreOrderedByPrice(): void self::assertSame('BTC', $sorted->last()->symbol); } - public function testFilterAndMapWhenCryptoCurrenciesThenMatchingSymbolsAreReturned(): void + public function testReduceWhenSummingAcrossStrategiesThenBothYieldSameSum(): 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') - ]); + /** @Given a set of elements */ + $elements = [1, 2, 3, 4, 5]; - /** @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); + /** @And an accumulator that sums values */ + $accumulator = static fn(int $carry, int $value): int => ($carry + $value); - /** @Then the symbols should be BTC, ETH, SOL in alphabetical order */ - self::assertSame(['BTC', 'ETH', 'SOL'], $symbols->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @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 testFlattenWhenOrdersHoldNestedProductsThenProductsAreFlattened(): void + public function testEqualsWhenSameElementsAcrossStrategiesThenComparisonIsTrue(): 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)) - ])) - ]); + /** @Given a lazy collection with elements 1, 2, 3 */ + $lazy = Collection::createLazyFrom(elements: [1, 2, 3]); - /** @When extracting all products and flattening */ - $allProducts = $orders - ->map(transformations: static fn(Order $order): array => iterator_to_array($order->products)) - ->flatten(); + /** @And an eager collection with elements 1, 2, 3 */ + $eager = Collection::createFrom(elements: [1, 2, 3]); - /** @Then there should be three products */ - self::assertSame(3, $allProducts->count()); + /** @When comparing lazy equals eager */ + $lazyEqualsEager = $lazy->equals(other: $eager); - /** @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); + /** @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 testFilterWhenStatusEnumElementsThenOnlyMatchingStatusesRemain(): void @@ -454,157 +416,78 @@ public function testFilterWhenStatusEnumElementsThenOnlyMatchingStatusesRemain() self::assertSame(3, $paid->count()); } - public function testGroupByWhenStatusEnumElementsThenGroupedByStatusName(): 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 testSortWhenLazyTypedInvoicesThenTypeIsPreserved(): 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 testSliceWhenTypedInvoicesThenSubsetPreservesType(): 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 testRemoveWhenInvoiceIsPresentThenElementIsRemoved(): void + public function testFlattenWhenOrdersHoldNestedProductsThenProductsAreFlattened(): 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') + /** @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 removing that invoice */ - $actual = $invoices->remove(element: $toRemove); + /** @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 two invoices remaining */ - self::assertSame(2, $actual->count()); + /** @Then there should be three products */ + self::assertSame(3, $allProducts->count()); - /** @And the total should be 400 */ - self::assertSame(400.00, $actual->totalAmount()); + /** @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 testJoinToStringWhenMappedNamesThenNamesAreJoined(): void + public function testGroupByWhenDragonsShareDescriptionsThenGroupedByDescription(): 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 testFilterWhenTypedInvoiceSummariesThenTypeIsPreserved(): 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 Dragon(name: 'Drogon', description: 'fire'), + new Dragon(name: 'Rhaegal', description: 'fire'), + new Dragon(name: 'Frostfyre', description: 'ice') ]); - /** @When filtering for Alice */ - $filtered = $summaries->filter( - predicates: static fn(InvoiceSummary $summary): bool => $summary->customer === 'Alice' + /** @When grouping by description */ + $grouped = $dragons->groupBy( + classifier: static fn(Dragon $dragon): string => $dragon->description ); - /** @Then the result should still be an instance of InvoiceSummaries */ - self::assertInstanceOf(InvoiceSummaries::class, $filtered); + /** @Then the fire group should contain three dragons */ + $groups = $grouped->toArray(); + self::assertCount(3, $groups['fire']); - /** @And it should contain one summary */ - self::assertSame(1, $filtered->count()); + /** @And the ice group should contain two dragons */ + self::assertCount(2, $groups['ice']); } - public function testMergeWhenTwoTypedCarriersThenTypeIsPreservedInOrder(): void + public function testFindByWhenDragonMatchesPredicateThenMatchingDragonIsReturned(): 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); + /** @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') + ]); - /** @Then the result should still be an instance of Carriers */ - self::assertInstanceOf(Carriers::class, $all); + /** @When finding the dragon named Viserion */ + $actual = $dragons->findBy( + predicates: static fn(Dragon $dragon): bool => $dragon->name === 'Viserion' + ); - /** @And it should contain four carriers */ - self::assertSame(4, $all->count()); + /** @Then it should return Viserion */ + self::assertSame('Viserion', $actual->name); - /** @And the carriers should be in the expected order */ - self::assertSame( - ['Correios', 'Jadlog', 'DHL', 'FedEx'], - $all->toArray(keyPreservation: KeyPreservation::DISCARD) - ); + /** @And Viserion should be an ice dragon */ + self::assertSame('ice', $actual->description); } public function testPipelineWhenClosureAndDirectStrategiesThenResultsAreIdentical(): void @@ -656,41 +539,158 @@ public function testPipelineWhenClosureAndDirectStrategiesThenResultsAreIdentica self::assertSame($lazyClosureResult, $eagerResult); } - public function testMapWhenLazyClosureBackedCarriersThenTypeIsPreserved(): void + public function testPipelineWhenObjectElementsThenLazyAndEagerProduceEqualResults(): void { - /** @Given a lazy closure-backed Carriers collection */ - $carriers = Carriers::createLazyFromClosure(factory: static function (): array { - return ['dhl', 'fedex', 'ups']; - }); + /** @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) + ]; - /** @When mapping to uppercase */ - $actual = $carriers->map( - transformations: static fn(string $name): string => strtoupper($name) + /** @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 ); - /** @Then the result should still be an instance of Carriers */ - self::assertInstanceOf(Carriers::class, $actual); + /** @And a removeAll predicate for amounts greater than 300 */ + $removeAll = static fn(Amount $amount): bool => $amount->value > 300; - /** @And the carriers should be uppercased */ - self::assertSame(['DHL', 'FEDEX', 'UPS'], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @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 testMapWhenEagerClosureBackedCarriersThenTypeIsPreserved(): void + public function testFilterAndMapWhenCryptoCurrenciesThenMatchingSymbolsAreReturned(): void { - /** @Given an eager closure-backed Carriers collection */ - $carriers = Carriers::createFromClosure(factory: static function (): array { - return ['dhl', 'fedex', 'ups']; - }); + /** @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 mapping to uppercase */ - $actual = $carriers->map( - transformations: static fn(string $name): string => strtoupper($name) - ); + /** @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 result should still be an instance of Carriers */ - self::assertInstanceOf(Carriers::class, $actual); + /** @Then the symbols should be BTC, ETH, SOL in alphabetical order */ + self::assertSame(['BTC', 'ETH', 'SOL'], $symbols->toArray(keyPreservation: KeyPreservation::DISCARD)); + } - /** @And the carriers should be uppercased */ - self::assertSame(['DHL', 'FEDEX', 'UPS'], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + public function testPipelineWhenIntegerElementsThenLazyAndEagerProduceIdenticalArrays(): void + { + /** @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 testSumByCustomerWhenCustomerHasMultipleSummariesThenAmountsAreSummed(): 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 testSumByCustomerWhenOtherCustomerHasMultipleSummariesThenAmountsAreSummed(): 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 testForCustomerWhenInvoicesForDifferentCustomersThenOnlyMatchingAreReturned(): 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()); } } diff --git a/tests/EagerCollectionTest.php b/tests/EagerCollectionTest.php index 75664b3..576f916 100644 --- a/tests/EagerCollectionTest.php +++ b/tests/EagerCollectionTest.php @@ -19,21 +19,6 @@ final class EagerCollectionTest extends TestCase { - public function testCreateFromWhenIntegerElementsThenHoldsAllElements(): void - { - /** @Given a set of integer elements */ - $elements = [1, 2, 3]; - - /** @When creating an eager collection from those elements */ - $collection = Collection::createFrom(elements: $elements); - - /** @Then the collection should contain all three elements */ - self::assertSame(3, $collection->count()); - - /** @And the array should match the original elements */ - self::assertSame([1, 2, 3], $collection->toArray()); - } - public function testCreateFromEmptyThenCollectionIsEmpty(): void { /** @When creating an eager collection without arguments */ @@ -46,90 +31,58 @@ public function testCreateFromEmptyThenCollectionIsEmpty(): void self::assertSame(0, $collection->count()); } - public function testCreateFromWhenGeneratorSourceThenAllElementsAreMaterialized(): void + public function testFindByWhenNoPredicatesThenReturnsNull(): void { - /** @Given a generator that yields three elements */ - $generator = (static function (): Generator { - yield 1; - yield 2; - yield 3; - })(); + /** @Given an eager collection with truthy and falsy values */ + $collection = Collection::createFrom(elements: [0, 1, 2]); - /** @When creating an eager collection from the generator */ - $collection = Collection::createFrom(elements: $generator); + /** @When finding without predicates */ + $actual = $collection->findBy(); - /** @Then the collection should materialize all three elements */ - self::assertSame(3, $collection->count()); + /** @Then it should return null */ + self::assertNull($actual); } - public function testCreateFromWhenGeneratorIsConsumedTwiceThenAllElementsRemainAvailable(): void + public function testSliceWhenLengthIsZeroThenResultIsEmpty(): 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); + /** @Given an eager collection with five elements */ + $collection = Collection::createFrom(elements: [10, 20, 30, 40, 50]); - /** @And consuming the collection via count */ - $count = $collection->count(); + /** @When slicing with length zero */ + $actual = $collection->slice(offset: 0, length: 0); - /** @Then the count should be 3 */ - self::assertSame(3, $count); + /** @Then the result should be empty */ + self::assertTrue($actual->isEmpty()); - /** @And a subsequent toArray should still return all elements */ - self::assertSame([1, 2, 3], $collection->toArray()); + /** @And the count should be zero */ + self::assertSame(0, $actual->count()); } - public function testAddWhenElementsAppendedThenNewCollectionContainsAllInOrder(): void + public function testFindByWhenNoElementMatchesThenReturnsNull(): void { - /** @Given an eager collection with three elements */ + /** @Given an eager collection of integers */ $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)); + /** @When finding an element greater than 100 */ + $actual = $collection->findBy(predicates: static fn(int $value): bool => $value > 100); - /** @And the original collection should remain unchanged */ - self::assertSame(3, $collection->count()); + /** @Then it should return null */ + self::assertNull($actual); } - public function testMergeWhenTwoCollectionsThenElementsAreCombinedInOrder(): void + public function testAddWhenElementAddedThenOriginalIsUnchanged(): 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)); - } + /** @Given an eager collection with three elements */ + $original = Collection::createFrom(elements: [1, 2, 3]); - public function testContainsWhenElementIsPresentThenReturnsTrue(): void - { - /** @Given an eager collection with integers */ - $collection = Collection::createFrom(elements: [1, 2, 3]); + /** @When adding a new element */ + $modified = $original->add(4); - /** @When checking for an element that exists */ - $actual = $collection->contains(element: 2); + /** @Then the original collection should remain unchanged */ + self::assertSame(3, $original->count()); - /** @Then it should return true */ - self::assertTrue($actual); + /** @And the new collection should have four elements */ + self::assertSame(4, $modified->count()); } public function testContainsWhenElementIsAbsentThenReturnsFalse(): void @@ -144,110 +97,61 @@ public function testContainsWhenElementIsAbsentThenReturnsFalse(): void self::assertFalse($actual); } - public function testContainsWhenEquivalentObjectIsPresentThenReturnsTrue(): void + public function testContainsWhenElementIsPresentThenReturnsTrue(): 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) - ]); + /** @Given an eager collection with integers */ + $collection = Collection::createFrom(elements: [1, 2, 3]); - /** @When checking if the collection contains an equivalent Amount */ - $actual = $collection->contains(element: $target); + /** @When checking for an element that exists */ + $actual = $collection->contains(element: 2); /** @Then it should return true */ self::assertTrue($actual); } - public function testContainsWhenObjectSoughtInScalarCollectionThenReturnsFalse(): 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 testContainsWhenScalarSoughtInObjectCollectionThenReturnsFalse(): 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 testCountWhenCollectionHasElementsThenReturnsElementCount(): 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 testFindByWhenElementMatchesPredicateThenFirstMatchIsReturned(): void + public function testIsEmptyWhenCollectionIsEmptyThenReturnsTrue(): 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); + /** @Given an empty eager collection */ + $empty = Collection::createFromEmpty(); - /** @Then it should return 4 */ - self::assertSame(4, $actual); + /** @Then the empty collection should return true */ + self::assertTrue($empty->isEmpty()); } - public function testFindByWhenMultiplePredicatesThenFirstMatchAcrossPredicatesIsReturned(): void + public function testToJsonWhenKeysPreservedThenReturnsJsonObject(): 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; + /** @Given an eager collection with string keys */ + $collection = Collection::createFrom(elements: ['x' => 1, 'y' => 2]); - $actual = $collection->findBy($matchTen, $matchThree); + /** @When converting to JSON without arguments */ + $actual = $collection->toJson(); - /** @Then it should return the first element matching any predicate */ - self::assertSame(3, $actual); + /** @Then the result should preserve keys as a JSON object */ + self::assertSame('{"x":1,"y":2}', $actual); } - public function testFindByWhenNoPredicatesThenReturnsNull(): void + public function testGetByWhenIndexExistsThenReturnsElementAtIndex(): void { - /** @Given an eager collection with truthy and falsy values */ - $collection = Collection::createFrom(elements: [0, 1, 2]); + /** @Given an eager collection with three elements */ + $collection = Collection::createFrom(elements: ['a', 'b', 'c']); - /** @When finding without predicates */ - $actual = $collection->findBy(); + /** @When retrieving the element at index 1 */ + $actual = $collection->getBy(index: 1); - /** @Then it should return null */ - self::assertNull($actual); + /** @Then it should return 'b' */ + self::assertSame('b', $actual); } - public function testFindByWhenNoElementMatchesThenReturnsNull(): void + public function testToJsonWhenIntegerElementsThenReturnsJsonArray(): 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); + /** @When converting to JSON */ + $actual = $collection->toJson(); - /** @Then it should return null */ - self::assertNull($actual); + /** @Then the result should be a valid JSON array */ + self::assertSame('[1,2,3]', $actual); } public function testEachWhenActionAppliedThenEveryElementIsVisited(): void @@ -267,924 +171,942 @@ public function testEachWhenActionAppliedThenEveryElementIsVisited(): void self::assertSame(6, $sum); } - public function testEqualsWhenCollectionsHaveIdenticalElementsThenReturnsTrue(): void + public function testFilterWhenNoPredicateThenFalsyValuesAreRemoved(): 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]); + /** @Given an eager collection with falsy and truthy values */ + $collection = Collection::createFrom(elements: [0, '', null, false, 1, 'hello', 2]); - /** @When comparing them for equality */ - $actual = $first->equals(other: $second); + /** @When filtering without a predicate */ + $actual = $collection->filter(); - /** @Then they should be equal */ - self::assertTrue($actual); + /** @Then only truthy values should remain */ + self::assertSame([1, 'hello', 2], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testEqualsWhenCollectionsHaveDifferentElementsThenReturnsFalse(): void + public function testToArrayWhenKeysDiscardedThenValuesAreReindexed(): 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]); + /** @Given an eager collection with non-sequential keys */ + $collection = Collection::createFrom(elements: [0 => 'a', 2 => 'b', 5 => 'c']); - /** @When comparing them for equality */ - $actual = $first->equals(other: $second); + /** @When converting to array discarding keys */ + $actual = $collection->toArray(keyPreservation: KeyPreservation::DISCARD); - /** @Then they should not be equal */ - self::assertFalse($actual); + /** @Then the keys should be re-indexed from 0 */ + self::assertSame(['a', 'b', 'c'], $actual); } - public function testEqualsWhenCollectionsHaveDifferentSizesThenReturnsFalseBothWays(): void + public function testIsEmptyWhenCollectionIsNotEmptyThenReturnsFalse(): void { - /** @Given a first eager collection with three elements */ - $first = Collection::createFrom(elements: [1, 2, 3]); + /** @Given a non-empty eager collection */ + $nonEmpty = Collection::createFrom(elements: [1]); - /** @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); + /** @Then the non-empty collection should return false */ + self::assertFalse($nonEmpty->isEmpty()); } - public function testEqualsWhenLongerCollectionSharesPrefixThenReturnsFalse(): void + public function testRemoveAllWhenNoPredicateThenCollectionIsEmptied(): 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]); + /** @Given an eager collection of integers */ + $collection = Collection::createFrom(elements: [1, 2, 3]); - /** @When comparing them for equality */ - $actual = $first->equals(other: $second); + /** @When removing all without a predicate */ + $actual = $collection->removeAll(); - /** @Then they should not be equal */ - self::assertFalse($actual); + /** @Then the collection should be empty */ + self::assertSame(0, $actual->count()); } - public function testEqualsWhenTrailingNullExtendsCollectionThenReturnsFalse(): void + public function testLastWhenCollectionIsEmptyThenReturnsDefaultValue(): 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]); + /** @Given an empty eager collection */ + $collection = Collection::createFromEmpty(); - /** @When comparing them for equality */ - $actual = $first->equals(other: $second); + /** @When retrieving the last element with a default */ + $actual = $collection->last(defaultValueIfNotFound: 'fallback'); - /** @Then they should not be equal */ - self::assertFalse($actual); + /** @Then it should return the default value */ + self::assertSame('fallback', $actual); } - public function testRemoveWhenValueHasDuplicatesThenAllOccurrencesAreRemoved(): void + public function testMapWhenStringKeyedCollectionThenKeysArePreserved(): void { - /** @Given an eager collection with duplicate elements */ - $collection = Collection::createFrom(elements: [1, 2, 3, 2, 4]); + /** @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); + /** @When transforming each element */ + $actual = $collection->map(transformations: static fn(int $value): int => ($value * 10)); - /** @Then all occurrences of 2 should be removed */ - self::assertSame([1, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then the keys should be preserved */ + self::assertSame(['a' => 10, 'b' => 20, 'c' => 30], $actual->toArray()); } - public function testRemoveWhenScalarRemovedFromObjectCollectionThenNothingIsRemoved(): void + public function testSliceWhenNegativeLengthThenRemainingCountIsExact(): 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) - ]); + /** @Given an eager collection with six elements */ + $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5, 6]); - /** @When removing a scalar value */ - $actual = $collection->remove(element: 50.00); + /** @When slicing from offset 0 with length -3 (exclude last 3) */ + $actual = $collection->slice(offset: 0, length: -3); - /** @Then no elements should be removed */ - self::assertSame(2, $actual->count()); + /** @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 testRemoveWhenStringKeyedCollectionThenRemainingKeysArePreserved(): void + public function testToArrayWhenNonSequentialKeysThenKeysArePreserved(): void { - /** @Given an eager collection with string keys */ - $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); + /** @Given an eager collection with non-sequential keys */ + $collection = Collection::createFrom(elements: [0 => 'a', 2 => 'b', 5 => 'c']); - /** @When removing the value 2 */ - $actual = $collection->remove(element: 2); + /** @When converting to array preserving keys */ + $actual = $collection->toArray(); - /** @Then the remaining keys should be preserved */ - self::assertSame(['a' => 1, 'c' => 3], $actual->toArray()); + /** @Then the keys should be preserved */ + self::assertSame([0 => 'a', 2 => 'b', 5 => 'c'], $actual); } - public function testRemoveAllWhenPredicateGivenThenMatchingElementsAreRemoved(): void + public function testCreateFromWhenIntegerElementsThenHoldsAllElements(): void { - /** @Given an eager collection of integers */ - $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5]); + /** @Given a set of integer elements */ + $elements = [1, 2, 3]; - /** @When removing all elements greater than 3 */ - $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 3); + /** @When creating an eager collection from those elements */ + $collection = Collection::createFrom(elements: $elements); - /** @Then only elements 1, 2, 3 should remain */ - self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then the collection should contain all three elements */ + self::assertSame(3, $collection->count()); + + /** @And the array should match the original elements */ + self::assertSame([1, 2, 3], $collection->toArray()); } - public function testRemoveAllWhenNoPredicateThenCollectionIsEmptied(): void + public function testFirstWhenCollectionIsEmptyThenReturnsDefaultValue(): void { - /** @Given an eager collection of integers */ - $collection = Collection::createFrom(elements: [1, 2, 3]); + /** @Given an empty eager collection */ + $collection = Collection::createFromEmpty(); - /** @When removing all without a predicate */ - $actual = $collection->removeAll(); + /** @When retrieving the first element with a default */ + $actual = $collection->first(defaultValueIfNotFound: 'fallback'); - /** @Then the collection should be empty */ - self::assertSame(0, $actual->count()); + /** @Then it should return the default value */ + self::assertSame('fallback', $actual); } - public function testRemoveAllWhenFirstElementDoesNotMatchThenOnlyMatchingAreRemoved(): void + public function testSortWhenDefaultKeyOrderThenKeysAreSortedAscending(): void { - /** @Given an eager collection where the first element does not match the predicate */ - $collection = Collection::createFrom(elements: [1, 10, 2, 20, 3]); + /** @Given an eager collection with unordered string keys */ + $collection = Collection::createFrom(elements: ['c' => 3, 'a' => 1, 'b' => 2]); - /** @When removing all elements greater than 5 */ - $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 5); + /** @When sorting by ascending key */ + $actual = $collection->sort(); - /** @Then elements 1, 2, 3 should remain */ - self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then the keys should be in ascending order */ + self::assertSame(['a' => 1, 'b' => 2, 'c' => 3], $actual->toArray()); } - public function testRemoveAllWhenStringKeyedCollectionThenRemainingKeysArePreserved(): void + public function testGetByWhenIndexIsOutOfBoundsThenReturnsDefaultValue(): void { - /** @Given an eager collection with string keys */ - $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); + /** @Given an eager collection with three elements */ + $collection = Collection::createFrom(elements: ['a', 'b', 'c']); - /** @When removing elements greater than 2 */ - $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 2); + /** @When retrieving an element at an index that does not exist */ + $actual = $collection->getBy(index: 99, defaultValueIfNotFound: 'missing'); - /** @Then the remaining keys should be preserved */ - self::assertSame(['a' => 1, 'b' => 2], $actual->toArray()); + /** @Then it should return the default value */ + self::assertSame('missing', $actual); } - public function testFirstWhenCollectionIsNotEmptyThenReturnsFirstElement(): void + public function testLastWhenCollectionIsNotEmptyThenReturnsLastElement(): void { /** @Given an eager collection with three elements */ $collection = Collection::createFrom(elements: [10, 20, 30]); - /** @When retrieving the first element */ - $actual = $collection->first(); + /** @When retrieving the last element */ + $actual = $collection->last(); - /** @Then it should return 10 */ - self::assertSame(10, $actual); + /** @Then it should return 30 */ + self::assertSame(30, $actual); } - public function testFirstWhenCollectionIsEmptyThenReturnsDefaultValue(): void + public function testLastWhenLastElementIsNullThenReturnsNullNotDefault(): void { - /** @Given an empty eager collection */ - $collection = Collection::createFromEmpty(); + /** @Given an eager collection where the last element is null */ + $collection = Collection::createFrom(elements: [1, 2, null]); - /** @When retrieving the first element with a default */ - $actual = $collection->first(defaultValueIfNotFound: 'fallback'); + /** @When retrieving the last element with a default */ + $actual = $collection->last(defaultValueIfNotFound: 'fallback'); - /** @Then it should return the default value */ - self::assertSame('fallback', $actual); + /** @Then it should return null, not the default */ + self::assertNull($actual); } - public function testFirstWhenCollectionIsEmptyWithoutDefaultThenReturnsNull(): void + public function testReduceWhenClosureBackedObjectsThenAmountsAreSummed(): void { - /** @Given an empty eager collection */ - $collection = Collection::createFromEmpty(); + /** @Given a closure that returns Amount objects */ + $collection = Collection::createFromClosure(factory: static function (): array { + return [ + new Amount(value: 100.00, currency: Currency::USD), + new Amount(value: 200.00, currency: Currency::USD), + new Amount(value: 300.00, currency: Currency::USD), + ]; + }); - /** @When retrieving the first element without a default */ - $actual = $collection->first(); + /** @When reducing to sum all amounts */ + $total = $collection->reduce( + accumulator: static fn(float $carry, Amount $amount): float => ($carry + $amount->value), + initial: 0.0 + ); - /** @Then it should return null */ - self::assertNull($actual); + /** @Then the total should be 600 */ + self::assertSame(600.00, $total); } - public function testFirstWhenFirstElementIsNullThenReturnsNullNotDefault(): void + public function testSliceWhenStringKeyedCollectionThenKeysArePreserved(): void { - /** @Given an eager collection where the first element is null */ - $collection = Collection::createFrom(elements: [null, 1, 2]); + /** @Given an eager collection with string keys */ + $collection = Collection::createFrom(elements: ['a' => 10, 'b' => 20, 'c' => 30, 'd' => 40]); - /** @When retrieving the first element with a default */ - $actual = $collection->first(defaultValueIfNotFound: 'fallback'); + /** @When slicing from offset 1 with length 2 */ + $actual = $collection->slice(offset: 1, length: 2); - /** @Then it should return null, not the default */ - self::assertNull($actual); + /** @Then the keys should be preserved */ + self::assertSame(['b' => 20, 'c' => 30], $actual->toArray()); } - public function testFlattenWhenNestedArraysThenElementsAreLiftedOneLevel(): void + public function testJoinToStringWhenSingleElementThenSeparatorIsOmitted(): void { - /** @Given an eager collection with nested arrays */ - $collection = Collection::createFrom(elements: [[1, 2], [3, 4], 5]); + /** @Given an eager collection with a single integer */ + $collection = Collection::createFrom(elements: [42]); - /** @When flattening by one level */ - $actual = $collection->flatten(); + /** @When joining to string */ + $actual = $collection->joinToString(separator: ', '); - /** @Then all elements should be at the top level */ - self::assertSame([1, 2, 3, 4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then the result should be a string */ + self::assertSame('42', $actual); } - public function testGetByWhenIndexExistsThenReturnsElementAtIndex(): void + public function testMapWhenMultipleTransformationsThenAppliedInSequence(): void { - /** @Given an eager collection with three elements */ - $collection = Collection::createFrom(elements: ['a', 'b', 'c']); + /** @Given an eager collection of integers */ + $collection = Collection::createFrom(elements: [1, 2, 3]); - /** @When retrieving the element at index 1 */ - $actual = $collection->getBy(index: 1); + /** @When applying two transformations: increment, then double */ + $actual = $collection->map( + static fn(int $value): int => ($value + 1), + static fn(int $value): int => ($value * 2) + ); - /** @Then it should return 'b' */ - self::assertSame('b', $actual); + /** @Then both transformations should be applied in order */ + self::assertSame([4, 6, 8], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testGetByWhenIndexIsOutOfBoundsThenReturnsDefaultValue(): void + public function testSliceWhenOffsetAndLengthGivenThenSubrangeIsReturned(): void { - /** @Given an eager collection with three elements */ - $collection = Collection::createFrom(elements: ['a', 'b', 'c']); + /** @Given an eager collection of five elements */ + $collection = Collection::createFrom(elements: [10, 20, 30, 40, 50]); - /** @When retrieving an element at an index that does not exist */ - $actual = $collection->getBy(index: 99, defaultValueIfNotFound: 'missing'); + /** @When slicing from offset 1 with length 2 */ + $actual = $collection->slice(offset: 1, length: 2); - /** @Then it should return the default value */ - self::assertSame('missing', $actual); + /** @Then the result should contain elements 20 and 30 */ + self::assertSame([20, 30], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testGroupByWhenClassifierGivenThenElementsAreGroupedByClassifier(): void + public function testSliceWhenOffsetWithoutLengthThenRemainderIsReturned(): 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' - ); + /** @Given an eager collection of five elements */ + $collection = Collection::createFrom(elements: [10, 20, 30, 40, 50]); - /** @Then the odd group should contain 1, 3, 5 */ - $groups = $actual->toArray(); - self::assertSame([1, 3, 5], $groups['odd']); + /** @When slicing from offset 2 without specifying length */ + $actual = $collection->slice(offset: 2); - /** @And the even group should contain 2, 4, 6 */ - self::assertSame([2, 4, 6], $groups['even']); + /** @Then the result should contain all elements from index 2 onward */ + self::assertSame([30, 40, 50], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testIsEmptyWhenCollectionIsEmptyThenReturnsTrue(): void + public function testToShipmentsWhenRawRecordsThenTypedCollectionIsBuilt(): void { - /** @Given an empty eager collection */ - $empty = Collection::createFromEmpty(); + /** @Given raw shipment records as arrays */ + $records = [ + [ + 'id' => 'SHP-001', + 'status' => 'shipped', + 'carrier' => 'DHL', + 'created_at' => '2026-01-10T08:00:00+00:00', + 'customer_id' => 'C-100' + ], + [ + 'id' => 'SHP-002', + 'status' => 'pending', + 'carrier' => 'FedEx', + 'created_at' => '2026-01-11T09:00:00+00:00', + 'customer_id' => 'C-200' + ], + [ + 'id' => 'SHP-003', + 'status' => 'shipped', + 'carrier' => 'UPS', + 'created_at' => '2026-01-12T10:00:00+00:00', + 'customer_id' => 'C-100' + ], + ]; - /** @Then the empty collection should return true */ - self::assertTrue($empty->isEmpty()); - } + /** @When mapping records to a typed Shipments collection via ShipmentRecord */ + $shipments = ShipmentRecord::fromRecords(records: $records)->toShipments(); - public function testIsEmptyWhenCollectionIsNotEmptyThenReturnsFalse(): void - { - /** @Given a non-empty eager collection */ - $nonEmpty = Collection::createFrom(elements: [1]); + /** @Then the collection should contain three shipments */ + self::assertSame(3, $shipments->count()); - /** @Then the non-empty collection should return false */ - self::assertFalse($nonEmpty->isEmpty()); + /** @And the collection should be an instance of Shipments */ + self::assertInstanceOf(Shipments::class, $shipments); } - public function testJoinToStringWhenStringElementsThenValuesAreJoinedWithSeparator(): void + public function testContainsWhenEquivalentObjectIsPresentThenReturnsTrue(): void { - /** @Given an eager collection of strings */ - $collection = Collection::createFrom(elements: ['a', 'b', 'c']); + /** @Given an Amount object to search for */ + $target = new Amount(value: 100.00, currency: Currency::USD); - /** @When joining with a comma separator */ - $actual = $collection->joinToString(separator: ', '); + /** @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) + ]); - /** @Then the result should be "a, b, c" */ - self::assertSame('a, b, c', $actual); + /** @When checking if the collection contains an equivalent Amount */ + $actual = $collection->contains(element: $target); + + /** @Then it should return true */ + self::assertTrue($actual); } - public function testJoinToStringWhenIntegerElementsThenValuesAreJoinedWithSeparator(): void + public function testFirstWhenCollectionIsNotEmptyThenReturnsFirstElement(): void { - /** @Given an eager collection of integers */ - $collection = Collection::createFrom(elements: [1, 2, 3]); + /** @Given an eager collection with three elements */ + $collection = Collection::createFrom(elements: [10, 20, 30]); - /** @When joining with a comma separator */ - $actual = $collection->joinToString(separator: ', '); + /** @When retrieving the first element */ + $actual = $collection->first(); - /** @Then the result should be "1, 2, 3" */ - self::assertSame('1, 2, 3', $actual); + /** @Then it should return 10 */ + self::assertSame(10, $actual); } - public function testJoinToStringWhenSingleElementThenSeparatorIsOmitted(): void + public function testFirstWhenFirstElementIsNullThenReturnsNullNotDefault(): void { - /** @Given an eager collection with a single integer */ - $collection = Collection::createFrom(elements: [42]); + /** @Given an eager collection where the first element is null */ + $collection = Collection::createFrom(elements: [null, 1, 2]); - /** @When joining to string */ - $actual = $collection->joinToString(separator: ', '); + /** @When retrieving the first element with a default */ + $actual = $collection->first(defaultValueIfNotFound: 'fallback'); - /** @Then the result should be a string */ - self::assertSame('42', $actual); + /** @Then it should return null, not the default */ + self::assertNull($actual); } - public function testFilterWhenPredicateGivenThenOnlyMatchingElementsRemain(): void + public function testFlattenWhenNestedArraysThenElementsAreLiftedOneLevel(): void { - /** @Given an eager collection of integers */ - $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5]); + /** @Given an eager collection with nested arrays */ + $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); + /** @When flattening by one level */ + $actual = $collection->flatten(); - /** @Then only 4 and 5 should remain */ - self::assertSame([4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then all elements should be at the top level */ + self::assertSame([1, 2, 3, 4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testFilterWhenSinglePredicateMatchesFalsyValueThenItIsKept(): void + public function testMapWhenClosureBackedTypedCarriersThenTypeIsPreserved(): void { - /** @Given an eager collection containing a falsy value and a non-matching value */ - $collection = Collection::createFrom(elements: [0, 1, 2, 9]); + /** @Given a closure-backed Carriers collection */ + $carriers = Carriers::createFromClosure(factory: static function (): array { + return ['dhl', 'fedex', 'ups']; + }); - /** @When filtering with a single predicate that matches values below five */ - $actual = $collection->filter(predicates: static fn(int $value): bool => $value < 5); + /** @When mapping to uppercase */ + $actual = $carriers->map( + transformations: static fn(string $name): string => strtoupper($name) + ); - /** @Then the falsy value matched by the predicate is kept and the non-matching value is dropped */ - self::assertSame([0, 1, 2], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @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 testFilterWhenNoPredicateThenFalsyValuesAreRemoved(): void + public function testAddWhenClosureBackedCollectionThenElementsAreAppended(): void { - /** @Given an eager collection with falsy and truthy values */ - $collection = Collection::createFrom(elements: [0, '', null, false, 1, 'hello', 2]); + /** @Given a closure-backed eager collection */ + $collection = Collection::createFromClosure(factory: static function (): array { + return [1, 2]; + }); - /** @When filtering without a predicate */ - $actual = $collection->filter(); + /** @When adding elements */ + $actual = $collection->add(3, 4); - /** @Then only truthy values should remain */ - self::assertSame([1, 'hello', 2], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then all elements should be present */ + self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testFilterWhenExplicitNullPredicateThenFalsyValuesAreRemoved(): void + public function testCountWhenCollectionHasElementsThenReturnsElementCount(): void { - /** @Given an eager collection with falsy and truthy values */ - $collection = Collection::createFrom(elements: [0, '', 1, 'hello', 2]); + /** @Given an eager collection with five elements */ + $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5]); - /** @When filtering with an explicit null predicate */ - $actual = $collection->filter(null); + /** @When counting the elements */ + $actual = $collection->count(); - /** @Then only truthy values should remain */ - self::assertSame([1, 'hello', 2], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then it should return 5 */ + self::assertSame(5, $actual); } - public function testFilterWhenStringKeyedCollectionThenRemainingKeysArePreserved(): void + public function testMergeWhenTwoCollectionsThenElementsAreCombinedInOrder(): void { - /** @Given an eager collection with string keys */ - $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); + /** @Given a first eager collection */ + $first = Collection::createFrom(elements: [1, 2]); - /** @When filtering only elements greater than 1 */ - $actual = $collection->filter(predicates: static fn(int $value): bool => $value > 1); + /** @And a second eager collection */ + $second = Collection::createFrom(elements: [3, 4]); - /** @Then the remaining keys should be preserved */ - self::assertSame(['b' => 2, 'c' => 3], $actual->toArray()); + /** @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 testFilterWhenMultiplePredicatesThenOnlyElementsMatchingAllRemain(): void + public function testReduceWhenAppliedOverChainedPipelineThenSumIsReturned(): void { - /** @Given an eager collection of integers */ - $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + /** @Given an eager collection of integers from 1 to 100 */ + $collection = Collection::createFrom(elements: range(1, 100)); - /** @When filtering with two predicates: greater than 3 and even */ - $actual = $collection->filter( - static fn(int $value): bool => $value > 3, - static fn(int $value): bool => ($value % 2) === 0 + /** @And the collection is filtered to even numbers, squared, and sorted descending */ + $pipeline = $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); + + /** @When reducing to calculate the sum of all squared even numbers */ + $sum = $pipeline->reduce( + accumulator: static fn(int $carry, int $value): int => ($carry + $value), + initial: 0 ); - /** @Then only elements satisfying both predicates should remain */ - self::assertSame([4, 6, 8, 10], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then the sum should be 171700 */ + self::assertSame(171700, $sum); } - public function testFilterWhenAppliedAfterTerminalThenOperatesOnMaterializedElements(): void + public function testSortWhenComparatorDiffersFromDefaultThenOrdersDiverge(): void { - /** @Given an eager collection of integers */ - $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5]); + /** @Given an eager collection where alphabetical and length order diverge */ + $collection = Collection::createFrom(elements: ['zz', 'a', 'bbb']); - /** @And the collection materialized by a prior terminal call */ - $collection->count(); + /** @When sorting ascending by length */ + $byLength = $collection->sort( + order: Order::ASCENDING_VALUE, + comparator: static fn(string $first, string $second): int => (strlen($first) <=> strlen($second)) + ); - /** @When filtering the already-materialized collection */ - $actual = $collection->filter(predicates: static fn(int $value): bool => ($value % 2) === 0); + /** @And sorting ascending by default (alphabetical) */ + $byDefault = $collection->sort(order: Order::ASCENDING_VALUE); - /** @Then the filter operates on the cached elements */ - self::assertSame([2, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @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 testLastWhenCollectionIsNotEmptyThenReturnsLastElement(): void + public function testSortWhenDescendingKeyOrderThenKeysAreSortedDescending(): void { - /** @Given an eager collection with three elements */ - $collection = Collection::createFrom(elements: [10, 20, 30]); + /** @Given an eager collection with ordered string keys */ + $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); - /** @When retrieving the last element */ - $actual = $collection->last(); + /** @When sorting by descending key */ + $actual = $collection->sort(order: Order::DESCENDING_KEY); - /** @Then it should return 30 */ - self::assertSame(30, $actual); + /** @Then the keys should be in descending order */ + self::assertSame(['c' => 3, 'b' => 2, 'a' => 1], $actual->toArray()); } - public function testLastWhenCollectionIsEmptyThenReturnsDefaultValue(): void + public function testToJsonWhenKeysDiscardedThenReturnsSequentialJsonArray(): void { - /** @Given an empty eager collection */ - $collection = Collection::createFromEmpty(); + /** @Given an eager collection with string keys */ + $collection = Collection::createFrom(elements: ['x' => 1, 'y' => 2]); - /** @When retrieving the last element with a default */ - $actual = $collection->last(defaultValueIfNotFound: 'fallback'); + /** @When converting to JSON discarding keys */ + $actual = $collection->toJson(keyPreservation: KeyPreservation::DISCARD); - /** @Then it should return the default value */ - self::assertSame('fallback', $actual); + /** @Then the result should be a sequential JSON array */ + self::assertSame('[1,2]', $actual); } - public function testLastWhenLastElementIsNullThenReturnsNullNotDefault(): void + public function testCreateFromClosureWhenClosureSourceThenHoldsAllElements(): 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); - } + /** @Given a closure that returns three elements */ + $factory = static function (): array { + return [1, 2, 3]; + }; - public function testMapWhenTransformationGivenThenEachElementIsTransformed(): void - { - /** @Given an eager collection of integers */ - $collection = Collection::createFrom(elements: [1, 2, 3]); + /** @When creating an eager collection from the closure */ + $collection = Collection::createFromClosure(factory: $factory); - /** @When transforming each element by multiplying by 10 */ - $actual = $collection->map(transformations: static fn(int $value): int => ($value * 10)); + /** @Then the collection should contain all three elements */ + self::assertSame(3, $collection->count()); - /** @Then each element should be multiplied */ - self::assertSame([10, 20, 30], $actual->toArray()); + /** @And the array should match the expected elements */ + self::assertSame([1, 2, 3], $collection->toArray()); } - public function testMapWhenStringKeyedCollectionThenKeysArePreserved(): void + public function testEqualsWhenLongerCollectionSharesPrefixThenReturnsFalse(): void { - /** @Given an eager collection with string keys */ - $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); + /** @Given a first eager collection with four elements */ + $first = Collection::createFrom(elements: [1, 2, 3, 4]); - /** @When transforming each element */ - $actual = $collection->map(transformations: static fn(int $value): int => ($value * 10)); + /** @And a second eager collection with three elements */ + $second = Collection::createFrom(elements: [1, 2, 3]); - /** @Then the keys should be preserved */ - self::assertSame(['a' => 10, 'b' => 20, 'c' => 30], $actual->toArray()); + /** @When comparing them for equality */ + $actual = $first->equals(other: $second); + + /** @Then they should not be equal */ + self::assertFalse($actual); } - public function testMapWhenMultipleTransformationsThenAppliedInSequence(): void + public function testFilterWhenPredicateGivenThenOnlyMatchingElementsRemain(): void { /** @Given an eager collection of integers */ - $collection = Collection::createFrom(elements: [1, 2, 3]); + $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5]); - /** @When applying two transformations: increment, then double */ - $actual = $collection->map( - static fn(int $value): int => ($value + 1), - static fn(int $value): int => ($value * 2) - ); + /** @When keeping only elements greater than 3 */ + $actual = $collection->filter(predicates: static fn(int $value): bool => $value > 3); - /** @Then both transformations should be applied in order */ - self::assertSame([4, 6, 8], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then only 4 and 5 should remain */ + self::assertSame([4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testMapWhenNoTransformationsThenElementsAreReturnedUnchanged(): void + public function testFilterWhenSinglePredicateMatchesFalsyValueThenItIsKept(): void { - /** @Given an eager collection of integers */ - $collection = Collection::createFrom(elements: [1, 2, 3]); + /** @Given an eager collection containing a falsy value and a non-matching value */ + $collection = Collection::createFrom(elements: [0, 1, 2, 9]); - /** @When mapping without any transformation */ - $actual = $collection->map(); + /** @When filtering with a single predicate that matches values below five */ + $actual = $collection->filter(predicates: static fn(int $value): bool => $value < 5); - /** @Then the elements should be returned unchanged */ - self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then the falsy value matched by the predicate is kept and the non-matching value is dropped */ + self::assertSame([0, 1, 2], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testReduceWhenAccumulatorGivenThenElementsAreAccumulatedToSingleValue(): void + public function testMapWhenTransformationGivenThenEachElementIsTransformed(): void { /** @Given an eager collection of integers */ - $collection = Collection::createFrom(elements: [1, 2, 3, 4]); + $collection = Collection::createFrom(elements: [1, 2, 3]); - /** @When reducing to calculate the sum */ - $actual = $collection->reduce( - accumulator: static fn(int $carry, int $value): int => ($carry + $value), - initial: 0 - ); + /** @When transforming each element by multiplying by 10 */ + $actual = $collection->map(transformations: static fn(int $value): int => ($value * 10)); - /** @Then the sum should be 10 */ - self::assertSame(10, $actual); + /** @Then each element should be multiplied */ + self::assertSame([10, 20, 30], $actual->toArray()); } - public function testSortWhenAscendingValueOrderThenElementsAreSortedAscending(): void + public function testSliceWhenNegativeLengthThenTrailingElementsAreExcluded(): void { - /** @Given an eager collection with unordered elements */ - $collection = Collection::createFrom(elements: [3, 1, 2]); + /** @Given an eager collection with five elements */ + $collection = Collection::createFrom(elements: [10, 20, 30, 40, 50]); - /** @When sorting in ascending order by value */ - $actual = $collection->sort(order: Order::ASCENDING_VALUE); + /** @When slicing from offset 0 with length -2 (exclude last 2) */ + $actual = $collection->slice(offset: 0, length: -2); - /** @Then the elements should be in ascending order */ - self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then the result should contain the first three elements */ + self::assertSame([10, 20, 30], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testSortWhenDescendingValueOrderThenElementsAreSortedDescending(): void + public function testSliceWhenOffsetAndNegativeLengthThenSubrangeIsReturned(): void { - /** @Given an eager collection with ordered elements */ - $collection = Collection::createFrom(elements: [1, 2, 3]); + /** @Given an eager collection with five elements */ + $collection = Collection::createFrom(elements: [10, 20, 30, 40, 50]); - /** @When sorting in descending order by value */ - $actual = $collection->sort(order: Order::DESCENDING_VALUE); + /** @When slicing from offset 1 with length -2 (skip first, exclude last 2) */ + $actual = $collection->slice(offset: 1, length: -2); - /** @Then the elements should be in descending order */ - self::assertSame([3, 2, 1], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then the result should contain elements 20 and 30 */ + self::assertSame([20, 30], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testSortWhenDefaultKeyOrderThenKeysAreSortedAscending(): void + public function testEqualsWhenTrailingNullExtendsCollectionThenReturnsFalse(): void { - /** @Given an eager collection with unordered string keys */ - $collection = Collection::createFrom(elements: ['c' => 3, 'a' => 1, 'b' => 2]); + /** @Given a first eager collection with three elements */ + $first = Collection::createFrom(elements: [1, 2, 3]); - /** @When sorting by ascending key */ - $actual = $collection->sort(); + /** @And a second eager collection with four elements ending with null */ + $second = Collection::createFrom(elements: [1, 2, 3, null]); - /** @Then the keys should be in ascending order */ - self::assertSame(['a' => 1, 'b' => 2, 'c' => 3], $actual->toArray()); + /** @When comparing them for equality */ + $actual = $first->equals(other: $second); + + /** @Then they should not be equal */ + self::assertFalse($actual); } - public function testSortWhenDescendingKeyOrderThenKeysAreSortedDescending(): void + public function testFirstWhenCollectionIsEmptyWithoutDefaultThenReturnsNull(): void { - /** @Given an eager collection with ordered string keys */ - $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); + /** @Given an empty eager collection */ + $collection = Collection::createFromEmpty(); - /** @When sorting by descending key */ - $actual = $collection->sort(order: Order::DESCENDING_KEY); + /** @When retrieving the first element without a default */ + $actual = $collection->first(); - /** @Then the keys should be in descending order */ - self::assertSame(['c' => 3, 'b' => 2, 'a' => 1], $actual->toArray()); + /** @Then it should return null */ + self::assertNull($actual); } - public function testSortWhenAscendingValueWithoutComparatorThenDefaultComparisonIsUsed(): void + public function testSliceWhenNegativeLengthOnStringKeysThenKeysArePreserved(): void { - /** @Given an eager collection with unordered integers */ - $collection = Collection::createFrom(elements: [3, 1, 4, 1, 5]); + /** @Given an eager collection with string keys */ + $collection = Collection::createFrom(elements: ['a' => 10, 'b' => 20, 'c' => 30, 'd' => 40]); - /** @When sorting ascending by value without a custom comparator */ - $actual = $collection->sort(order: Order::ASCENDING_VALUE); + /** @When slicing from offset 0 with length -2 */ + $actual = $collection->slice(offset: 0, length: -2); - /** @Then the elements should be sorted by the default spaceship operator */ - self::assertSame([1, 1, 3, 4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then the keys should be preserved */ + self::assertSame(['a' => 10, 'b' => 20], $actual->toArray()); } - public function testSortWhenCustomComparatorGivenThenElementsAreOrderedByComparator(): void + public function testContainsWhenClosureBackedCollectionThenDetectsMembership(): 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) - ]); + /** @Given a closure-backed eager collection */ + $collection = Collection::createFromClosure(factory: static function (): array { + return ['alpha', 'beta', 'gamma']; + }); - /** @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) - ); + /** @When checking if the collection contains an existing element */ + $containsBeta = $collection->contains(element: 'beta'); - /** @Then the first element should have the lowest value */ - self::assertSame(100.00, $actual->first()->value); + /** @Then it should return true */ + self::assertTrue($containsBeta); - /** @And the last element should have the highest value */ - self::assertSame(300.00, $actual->last()->value); + /** @And checking for a non-existing element should return false */ + self::assertFalse($collection->contains(element: 'delta')); } - public function testSortWhenComparatorDiffersFromDefaultThenOrdersDiverge(): void + public function testFilterWhenExplicitNullPredicateThenFalsyValuesAreRemoved(): 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); + /** @Given an eager collection with falsy and truthy values */ + $collection = Collection::createFrom(elements: [0, '', 1, 'hello', 2]); - /** @Then the custom order should be by length */ - self::assertSame(['a', 'zz', 'bbb'], $byLength->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @When filtering with an explicit null predicate */ + $actual = $collection->filter(null); - /** @And the default order should be alphabetical */ - self::assertSame(['a', 'bbb', 'zz'], $byDefault->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then only truthy values should remain */ + self::assertSame([1, 'hello', 2], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testSliceWhenOffsetAndLengthGivenThenSubrangeIsReturned(): void + public function testMapWhenNoTransformationsThenElementsAreReturnedUnchanged(): void { - /** @Given an eager collection of five elements */ - $collection = Collection::createFrom(elements: [10, 20, 30, 40, 50]); + /** @Given an eager collection of integers */ + $collection = Collection::createFrom(elements: [1, 2, 3]); - /** @When slicing from offset 1 with length 2 */ - $actual = $collection->slice(offset: 1, length: 2); + /** @When mapping without any transformation */ + $actual = $collection->map(); - /** @Then the result should contain elements 20 and 30 */ - self::assertSame([20, 30], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then the elements should be returned unchanged */ + self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testSliceWhenOffsetWithoutLengthThenRemainderIsReturned(): void + public function testRemoveWhenValueHasDuplicatesThenAllOccurrencesAreRemoved(): 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); + /** @Given an eager collection with duplicate elements */ + $collection = Collection::createFrom(elements: [1, 2, 3, 2, 4]); - /** @Then the result should contain all elements from index 2 onward */ - self::assertSame([30, 40, 50], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @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 testSliceWhenStringKeyedCollectionThenKeysArePreserved(): void + public function testToArrayWhenTypedShipmentsThenSerializedToRecordStructure(): void { - /** @Given an eager collection with string keys */ - $collection = Collection::createFrom(elements: ['a' => 10, 'b' => 20, 'c' => 30, 'd' => 40]); + /** @Given raw shipment records as arrays */ + $records = [ + [ + 'id' => 'SHP-001', + 'status' => 'shipped', + 'carrier' => 'DHL', + 'created_at' => '2026-01-10T08:00:00+00:00', + 'customer_id' => 'C-100' + ], + ]; - /** @When slicing from offset 1 with length 2 */ - $actual = $collection->slice(offset: 1, length: 2); + /** @When mapping records via ShipmentRecord and converting to array */ + $actual = ShipmentRecord::fromRecords(records: $records)->toShipments()->toArray(); - /** @Then the keys should be preserved */ - self::assertSame(['b' => 20, 'c' => 30], $actual->toArray()); + /** @Then the serialized array should match the original record structure */ + self::assertSame([ + [ + 'id' => 'SHP-001', + 'status' => 'shipped', + 'carrier' => 'DHL', + 'created_at' => '2026-01-10T08:00:00+00:00', + 'customer_id' => 'C-100', + ], + ], $actual); } - public function testSliceWhenLengthIsZeroThenResultIsEmpty(): void + public function testEqualsWhenCollectionsHaveIdenticalElementsThenReturnsTrue(): void { - /** @Given an eager collection with five elements */ - $collection = Collection::createFrom(elements: [10, 20, 30, 40, 50]); + /** @Given a first eager collection */ + $first = Collection::createFrom(elements: [1, 2, 3]); - /** @When slicing with length zero */ - $actual = $collection->slice(offset: 0, length: 0); + /** @And a second eager collection with the same elements */ + $second = Collection::createFrom(elements: [1, 2, 3]); - /** @Then the result should be empty */ - self::assertTrue($actual->isEmpty()); + /** @When comparing them for equality */ + $actual = $first->equals(other: $second); - /** @And the count should be zero */ - self::assertSame(0, $actual->count()); + /** @Then they should be equal */ + self::assertTrue($actual); } - public function testSliceWhenNegativeLengthThenTrailingElementsAreExcluded(): void + public function testFindByWhenElementMatchesPredicateThenFirstMatchIsReturned(): void { - /** @Given an eager collection with five elements */ - $collection = Collection::createFrom(elements: [10, 20, 30, 40, 50]); + /** @Given an eager collection of integers */ + $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5]); - /** @When slicing from offset 0 with length -2 (exclude last 2) */ - $actual = $collection->slice(offset: 0, length: -2); + /** @When finding the first element greater than 3 */ + $actual = $collection->findBy(predicates: static fn(int $value): bool => $value > 3); - /** @Then the result should contain the first three elements */ - self::assertSame([10, 20, 30], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then it should return 4 */ + self::assertSame(4, $actual); } - public function testSliceWhenOffsetAndNegativeLengthThenSubrangeIsReturned(): void + public function testGetByWhenClosureBackedCollectionThenReturnsElementAtIndex(): void { - /** @Given an eager collection with five elements */ - $collection = Collection::createFrom(elements: [10, 20, 30, 40, 50]); + /** @Given a closure-backed eager collection */ + $collection = Collection::createFromClosure(factory: static function (): array { + return ['alpha', 'beta', 'gamma']; + }); - /** @When slicing from offset 1 with length -2 (skip first, exclude last 2) */ - $actual = $collection->slice(offset: 1, length: -2); + /** @When retrieving element at index 1 */ + $actual = $collection->getBy(index: 1); - /** @Then the result should contain elements 20 and 30 */ - self::assertSame([20, 30], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then it should return the second element */ + self::assertSame('beta', $actual); } - public function testSliceWhenNegativeLengthOnStringKeysThenKeysArePreserved(): void + public function testRemoveAllWhenPredicateGivenThenMatchingElementsAreRemoved(): void { - /** @Given an eager collection with string keys */ - $collection = Collection::createFrom(elements: ['a' => 10, 'b' => 20, 'c' => 30, 'd' => 40]); + /** @Given an eager collection of integers */ + $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5]); - /** @When slicing from offset 0 with length -2 */ - $actual = $collection->slice(offset: 0, length: -2); + /** @When removing all elements greater than 3 */ + $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 3); - /** @Then the keys should be preserved */ - self::assertSame(['a' => 10, 'b' => 20], $actual->toArray()); + /** @Then only elements 1, 2, 3 should remain */ + self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testSliceWhenNegativeLengthThenRemainingCountIsExact(): void + public function testSortWhenAscendingValueOrderThenElementsAreSortedAscending(): 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); + /** @Given an eager collection with unordered elements */ + $collection = Collection::createFrom(elements: [3, 1, 2]); - /** @Then the collection should contain exactly 3 elements */ - self::assertCount(3, $actual); + /** @When sorting in ascending order by value */ + $actual = $collection->sort(order: Order::ASCENDING_VALUE); - /** @And the elements should be 1, 2, 3 */ + /** @Then the elements should be in ascending order */ self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testToArrayWhenNonSequentialKeysThenKeysArePreserved(): void + public function testAddWhenElementsAppendedThenNewCollectionContainsAllInOrder(): void { - /** @Given an eager collection with non-sequential keys */ - $collection = Collection::createFrom(elements: [0 => 'a', 2 => 'b', 5 => 'c']); + /** @Given an eager collection with three elements */ + $collection = Collection::createFrom(elements: [1, 2, 3]); - /** @When converting to array preserving keys */ - $actual = $collection->toArray(); + /** @When adding two more elements */ + $actual = $collection->add(4, 5); - /** @Then the keys should be preserved */ - self::assertSame([0 => 'a', 2 => 'b', 5 => 'c'], $actual); + /** @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 testToArrayWhenKeysDiscardedThenValuesAreReindexed(): void + public function testContainsWhenObjectSoughtInScalarCollectionThenReturnsFalse(): void { - /** @Given an eager collection with non-sequential keys */ - $collection = Collection::createFrom(elements: [0 => 'a', 2 => 'b', 5 => 'c']); + /** @Given an eager collection containing boolean true */ + $collection = Collection::createFrom(elements: [true]); - /** @When converting to array discarding keys */ - $actual = $collection->toArray(keyPreservation: KeyPreservation::DISCARD); + /** @When checking if the collection contains an object */ + $actual = $collection->contains(element: new stdClass()); - /** @Then the keys should be re-indexed from 0 */ - self::assertSame(['a', 'b', 'c'], $actual); + /** @Then it should return false because object and scalar types differ */ + self::assertFalse($actual); } - public function testToJsonWhenIntegerElementsThenReturnsJsonArray(): void + public function testContainsWhenScalarSoughtInObjectCollectionThenReturnsFalse(): void { - /** @Given an eager collection of integers */ - $collection = Collection::createFrom(elements: [1, 2, 3]); + /** @Given an eager collection containing a stdClass object */ + $collection = Collection::createFrom(elements: [new stdClass()]); - /** @When converting to JSON */ - $actual = $collection->toJson(); + /** @When checking if the collection contains boolean true */ + $actual = $collection->contains(element: true); - /** @Then the result should be a valid JSON array */ - self::assertSame('[1,2,3]', $actual); + /** @Then it should return false because an object is not a scalar */ + self::assertFalse($actual); } - public function testToJsonWhenKeysDiscardedThenReturnsSequentialJsonArray(): void + public function testEqualsWhenCollectionsHaveDifferentElementsThenReturnsFalse(): void { - /** @Given an eager collection with string keys */ - $collection = Collection::createFrom(elements: ['x' => 1, 'y' => 2]); + /** @Given a first eager collection */ + $first = Collection::createFrom(elements: [1, 2, 3]); - /** @When converting to JSON discarding keys */ - $actual = $collection->toJson(keyPreservation: KeyPreservation::DISCARD); + /** @And a second eager collection with different elements */ + $second = Collection::createFrom(elements: [1, 2, 4]); - /** @Then the result should be a sequential JSON array */ - self::assertSame('[1,2]', $actual); + /** @When comparing them for equality */ + $actual = $first->equals(other: $second); + + /** @Then they should not be equal */ + self::assertFalse($actual); } - public function testToJsonWhenKeysPreservedThenReturnsJsonObject(): void + public function testCreateFromWhenGeneratorSourceThenAllElementsAreMaterialized(): void { - /** @Given an eager collection with string keys */ - $collection = Collection::createFrom(elements: ['x' => 1, 'y' => 2]); + /** @Given a generator that yields three elements */ + $generator = (static function (): Generator { + yield 1; + yield 2; + yield 3; + })(); - /** @When converting to JSON without arguments */ - $actual = $collection->toJson(); + /** @When creating an eager collection from the generator */ + $collection = Collection::createFrom(elements: $generator); - /** @Then the result should preserve keys as a JSON object */ - self::assertSame('{"x":1,"y":2}', $actual); + /** @Then the collection should materialize all three elements */ + self::assertSame(3, $collection->count()); } - public function testAddWhenElementAddedThenOriginalIsUnchanged(): void + public function testSortWhenDescendingValueOrderThenElementsAreSortedDescending(): void { - /** @Given an eager collection with three elements */ - $original = Collection::createFrom(elements: [1, 2, 3]); - - /** @When adding a new element */ - $modified = $original->add(4); + /** @Given an eager collection with ordered elements */ + $collection = Collection::createFrom(elements: [1, 2, 3]); - /** @Then the original collection should remain unchanged */ - self::assertSame(3, $original->count()); + /** @When sorting in descending order by value */ + $actual = $collection->sort(order: Order::DESCENDING_VALUE); - /** @And the new collection should have four elements */ - self::assertSame(4, $modified->count()); + /** @Then the elements should be in descending order */ + self::assertSame([3, 2, 1], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testChainedOperationsWhenObjectElementsThenPipelineProducesExpectedResult(): void + public function testCreateFromClosureWhenClosureMapsRecordsThenShipmentsAreBuilt(): 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; + /** @Given raw shipment records as arrays */ + $records = [ + [ + 'id' => 'SHP-001', + 'status' => 'shipped', + 'carrier' => 'DHL', + 'created_at' => '2026-01-10T08:00:00+00:00', + 'customer_id' => 'C-100' + ], + [ + 'id' => 'SHP-002', + 'status' => 'pending', + 'carrier' => 'FedEx', + 'created_at' => '2026-01-11T09:00:00+00:00', + 'customer_id' => 'C-200' + ], + ]; - /** @When chaining filter, map, removeAll and sort */ - $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) - ); - - /** @And accumulating the total discounted value via each */ - $actual->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); + /** @When creating a Shipments collection from a closure that maps records */ + $shipments = Shipments::createFromClosure( + factory: static function () use ($records): Collection { + return Collection::createFrom(elements: $records) + ->map(transformations: static fn(array $record): Shipment => Shipment::from( + id: $record['id'], + status: $record['status'], + carrier: $record['carrier'], + createdAt: $record['created_at'], + customerId: $record['customer_id'] + )); + } + ); - /** @And the first Amount should be 90 after the discount */ - self::assertSame(90.00, $actual->first()->value); + /** @Then the collection should contain two shipments */ + self::assertSame(2, $shipments->count()); - /** @And the last Amount should be 225 after the discount */ - self::assertSame(225.00, $actual->last()->value); + /** @And the first shipment should have the expected id */ + self::assertSame('SHP-001', $shipments->first()->id); } - public function testChainedOperationsWhenIntegerElementsThenPipelineProducesExpectedResult(): void + public function testFilterWhenStringKeyedCollectionThenRemainingKeysArePreserved(): 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); + /** @Given an eager collection with string keys */ + $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); - /** @Then the first element should be 10000 (square of 100) */ - self::assertSame(10000, $actual->first()); + /** @When filtering only elements greater than 1 */ + $actual = $collection->filter(predicates: static fn(int $value): bool => $value > 1); - /** @And the last element should be 4 (square of 2) */ - self::assertSame(4, $actual->last()); + /** @Then the remaining keys should be preserved */ + self::assertSame(['b' => 2, 'c' => 3], $actual->toArray()); } - public function testReduceWhenAppliedOverChainedPipelineThenSumIsReturned(): void + public function testGroupByWhenClassifierGivenThenElementsAreGroupedByClassifier(): void { - /** @Given an eager collection of integers from 1 to 100 */ - $collection = Collection::createFrom(elements: range(1, 100)); - - /** @And the collection is filtered to even numbers, squared, and sorted descending */ - $pipeline = $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); + /** @Given an eager collection of integers from 1 to 6 */ + $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5, 6]); - /** @When reducing to calculate the sum of all squared even numbers */ - $sum = $pipeline->reduce( - accumulator: static fn(int $carry, int $value): int => ($carry + $value), - initial: 0 + /** @When grouping by even and odd */ + $actual = $collection->groupBy( + classifier: static fn(int $value): string => ($value % 2) === 0 ? 'even' : 'odd' ); - /** @Then the sum should be 171700 */ - self::assertSame(171700, $sum); - } - - public function testCreateFromClosureWhenClosureSourceThenHoldsAllElements(): void - { - /** @Given a closure that returns three elements */ - $factory = static function (): array { - return [1, 2, 3]; - }; - - /** @When creating an eager collection from the closure */ - $collection = Collection::createFromClosure(factory: $factory); - - /** @Then the collection should contain all three elements */ - self::assertSame(3, $collection->count()); + /** @Then the odd group should contain 1, 3, 5 */ + $groups = $actual->toArray(); + self::assertSame([1, 3, 5], $groups['odd']); - /** @And the array should match the expected elements */ - self::assertSame([1, 2, 3], $collection->toArray()); + /** @And the even group should contain 2, 4, 6 */ + self::assertSame([2, 4, 6], $groups['even']); } - public function testCreateFromClosureWhenConsumedMultipleTimesThenRemainsReiterable(): void + public function testMergeWhenClosureBackedMergedWithEagerThenElementsAreCombined(): void { /** @Given a closure-backed eager collection */ - $collection = Collection::createFromClosure(factory: static function (): array { - return [10, 20, 30]; + $closureCollection = Collection::createFromClosure(factory: static function (): array { + return [1, 2]; }); - /** @When consuming the collection via count */ - $count = $collection->count(); + /** @And an eager collection */ + $eagerCollection = Collection::createFrom(elements: [3, 4]); - /** @Then the count should be 3 */ - self::assertSame(3, $count); + /** @When merging them */ + $actual = $closureCollection->merge(other: $eagerCollection); - /** @And a subsequent toArray should still return all elements */ - self::assertSame([10, 20, 30], $collection->toArray()); + /** @Then the result should contain all elements */ + self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } - /** @And first should return the first element */ - self::assertSame(10, $collection->first()); + public function testRemoveWhenStringKeyedCollectionThenRemainingKeysArePreserved(): void + { + /** @Given an eager collection with string keys */ + $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); - /** @And last should return the last element */ - self::assertSame(30, $collection->last()); + /** @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 testCreateFromClosureWhenClosureReturnsEmptyThenCollectionIsEmpty(): void @@ -1204,229 +1126,307 @@ public function testCreateFromClosureWhenClosureReturnsEmptyThenCollectionIsEmpt self::assertSame(0, $collection->count()); } - public function testChainedOperationsWhenClosureBackedThenPipelineProducesExpectedResult(): void + public function testFilterWhenMultiplePredicatesThenOnlyElementsMatchingAllRemain(): void { - /** @Given a closure-backed eager collection with integers */ - $collection = Collection::createFromClosure(factory: static function (): array { - return [5, 3, 1, 4, 2]; - }); + /** @Given an eager collection of integers */ + $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); - /** @When chaining filter, map and sort */ - $actual = $collection - ->filter(predicates: static fn(int $value): bool => $value > 2) - ->map(transformations: static fn(int $value): int => ($value * 10)) - ->sort(order: Order::ASCENDING_VALUE); + /** @When filtering with two predicates: greater than 3 and even */ + $actual = $collection->filter( + static fn(int $value): bool => $value > 3, + static fn(int $value): bool => ($value % 2) === 0 + ); - /** @Then the result should contain the filtered, mapped and sorted values */ - self::assertSame([30, 40, 50], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then only elements satisfying both predicates should remain */ + self::assertSame([4, 6, 8, 10], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testReduceWhenClosureBackedObjectsThenAmountsAreSummed(): void + public function testJoinToStringWhenStringElementsThenValuesAreJoinedWithSeparator(): void { - /** @Given a closure that returns Amount objects */ - $collection = Collection::createFromClosure(factory: static function (): array { - return [ - new Amount(value: 100.00, currency: Currency::USD), - new Amount(value: 200.00, currency: Currency::USD), - new Amount(value: 300.00, currency: Currency::USD), - ]; - }); + /** @Given an eager collection of strings */ + $collection = Collection::createFrom(elements: ['a', 'b', 'c']); - /** @When reducing to sum all amounts */ - $total = $collection->reduce( - accumulator: static fn(float $carry, Amount $amount): float => ($carry + $amount->value), - initial: 0.0 - ); + /** @When joining with a comma separator */ + $actual = $collection->joinToString(separator: ', '); - /** @Then the total should be 600 */ - self::assertSame(600.00, $total); + /** @Then the result should be "a, b, c" */ + self::assertSame('a, b, c', $actual); } - public function testGetByWhenClosureBackedCollectionThenReturnsElementAtIndex(): void + public function testCreateFromClosureWhenConsumedMultipleTimesThenRemainsReiterable(): void { /** @Given a closure-backed eager collection */ $collection = Collection::createFromClosure(factory: static function (): array { - return ['alpha', 'beta', 'gamma']; + return [10, 20, 30]; }); - /** @When retrieving element at index 1 */ - $actual = $collection->getBy(index: 1); + /** @When consuming the collection via count */ + $count = $collection->count(); - /** @Then it should return the second element */ - self::assertSame('beta', $actual); + /** @Then the count should be 3 */ + self::assertSame(3, $count); + + /** @And a subsequent toArray should still return all elements */ + self::assertSame([10, 20, 30], $collection->toArray()); + + /** @And first should return the first element */ + self::assertSame(10, $collection->first()); + + /** @And last should return the last element */ + self::assertSame(30, $collection->last()); } - public function testContainsWhenClosureBackedCollectionThenDetectsMembership(): void + public function testEqualsWhenCollectionsHaveDifferentSizesThenReturnsFalseBothWays(): void { - /** @Given a closure-backed eager collection */ - $collection = Collection::createFromClosure(factory: static function (): array { - return ['alpha', 'beta', 'gamma']; - }); + /** @Given a first eager collection with three elements */ + $first = Collection::createFrom(elements: [1, 2, 3]); - /** @When checking if the collection contains an existing element */ - $containsBeta = $collection->contains(element: 'beta'); + /** @And a second eager collection with two elements */ + $second = Collection::createFrom(elements: [1, 2]); - /** @Then it should return true */ - self::assertTrue($containsBeta); + /** @When comparing first equals second */ + $firstEqualsSecond = $first->equals(other: $second); - /** @And checking for a non-existing element should return false */ - self::assertFalse($collection->contains(element: 'delta')); + /** @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 testAddWhenClosureBackedCollectionThenElementsAreAppended(): void + public function testJoinToStringWhenIntegerElementsThenValuesAreJoinedWithSeparator(): void { - /** @Given a closure-backed eager collection */ - $collection = Collection::createFromClosure(factory: static function (): array { - return [1, 2]; - }); + /** @Given an eager collection of integers */ + $collection = Collection::createFrom(elements: [1, 2, 3]); - /** @When adding elements */ - $actual = $collection->add(3, 4); + /** @When joining with a comma separator */ + $actual = $collection->joinToString(separator: ', '); - /** @Then all elements should be present */ - self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then the result should be "1, 2, 3" */ + self::assertSame('1, 2, 3', $actual); } - public function testMergeWhenClosureBackedMergedWithEagerThenElementsAreCombined(): void + public function testRemoveAllWhenFirstElementDoesNotMatchThenOnlyMatchingAreRemoved(): void { - /** @Given a closure-backed eager collection */ - $closureCollection = Collection::createFromClosure(factory: static function (): array { - return [1, 2]; - }); + /** @Given an eager collection where the first element does not match the predicate */ + $collection = Collection::createFrom(elements: [1, 10, 2, 20, 3]); - /** @And an eager collection */ - $eagerCollection = Collection::createFrom(elements: [3, 4]); + /** @When removing all elements greater than 5 */ + $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 5); - /** @When merging them */ - $actual = $closureCollection->merge(other: $eagerCollection); + /** @Then elements 1, 2, 3 should remain */ + self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } - /** @Then the result should contain all elements */ - self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + public function testRemoveAllWhenStringKeyedCollectionThenRemainingKeysArePreserved(): 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 testToShipmentsWhenRawRecordsThenTypedCollectionIsBuilt(): void + public function testRemoveWhenScalarRemovedFromObjectCollectionThenNothingIsRemoved(): void { - /** @Given raw shipment records as arrays */ - $records = [ - [ - 'id' => 'SHP-001', - 'status' => 'shipped', - 'carrier' => 'DHL', - 'created_at' => '2026-01-10T08:00:00+00:00', - 'customer_id' => 'C-100' - ], - [ - 'id' => 'SHP-002', - 'status' => 'pending', - 'carrier' => 'FedEx', - 'created_at' => '2026-01-11T09:00:00+00:00', - 'customer_id' => 'C-200' - ], - [ - 'id' => 'SHP-003', - 'status' => 'shipped', - 'carrier' => 'UPS', - 'created_at' => '2026-01-12T10:00:00+00:00', - 'customer_id' => 'C-100' - ], - ]; + /** @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 mapping records to a typed Shipments collection via ShipmentRecord */ - $shipments = ShipmentRecord::fromRecords(records: $records)->toShipments(); + /** @When removing a scalar value */ + $actual = $collection->remove(element: 50.00); - /** @Then the collection should contain three shipments */ - self::assertSame(3, $shipments->count()); + /** @Then no elements should be removed */ + self::assertSame(2, $actual->count()); + } - /** @And the collection should be an instance of Shipments */ - self::assertInstanceOf(Shipments::class, $shipments); + public function testSortWhenCustomComparatorGivenThenElementsAreOrderedByComparator(): 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 testCreateFromClosureWhenClosureMapsRecordsThenShipmentsAreBuilt(): void + public function testFilterWhenAppliedAfterTerminalThenOperatesOnMaterializedElements(): void { - /** @Given raw shipment records as arrays */ - $records = [ - [ - 'id' => 'SHP-001', - 'status' => 'shipped', - 'carrier' => 'DHL', - 'created_at' => '2026-01-10T08:00:00+00:00', - 'customer_id' => 'C-100' - ], - [ - 'id' => 'SHP-002', - 'status' => 'pending', - 'carrier' => 'FedEx', - 'created_at' => '2026-01-11T09:00:00+00:00', - 'customer_id' => 'C-200' - ], - ]; + /** @Given an eager collection of integers */ + $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5]); - /** @When creating a Shipments collection from a closure that maps records */ - $shipments = Shipments::createFromClosure( - factory: static function () use ($records): Collection { - return Collection::createFrom(elements: $records) - ->map(transformations: static fn(array $record): Shipment => Shipment::from( - id: $record['id'], - status: $record['status'], - carrier: $record['carrier'], - createdAt: $record['created_at'], - customerId: $record['customer_id'] - )); - } + /** @And the collection materialized by a prior terminal call */ + $collection->count(); + + /** @When filtering the already-materialized collection */ + $actual = $collection->filter(predicates: static fn(int $value): bool => ($value % 2) === 0); + + /** @Then the filter operates on the cached elements */ + self::assertSame([2, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testReduceWhenAccumulatorGivenThenElementsAreAccumulatedToSingleValue(): 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 collection should contain two shipments */ - self::assertSame(2, $shipments->count()); + /** @Then the sum should be 10 */ + self::assertSame(10, $actual); + } - /** @And the first shipment should have the expected id */ - self::assertSame('SHP-001', $shipments->first()->id); + public function testSortWhenAscendingValueWithoutComparatorThenDefaultComparisonIsUsed(): 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 testToArrayWhenTypedShipmentsThenSerializedToRecordStructure(): void + public function testChainedOperationsWhenClosureBackedThenPipelineProducesExpectedResult(): void { - /** @Given raw shipment records as arrays */ - $records = [ - [ - 'id' => 'SHP-001', - 'status' => 'shipped', - 'carrier' => 'DHL', - 'created_at' => '2026-01-10T08:00:00+00:00', - 'customer_id' => 'C-100' - ], - ]; + /** @Given a closure-backed eager collection with integers */ + $collection = Collection::createFromClosure(factory: static function (): array { + return [5, 3, 1, 4, 2]; + }); - /** @When mapping records via ShipmentRecord and converting to array */ - $actual = ShipmentRecord::fromRecords(records: $records)->toShipments()->toArray(); + /** @When chaining filter, map and sort */ + $actual = $collection + ->filter(predicates: static fn(int $value): bool => $value > 2) + ->map(transformations: static fn(int $value): int => ($value * 10)) + ->sort(order: Order::ASCENDING_VALUE); - /** @Then the serialized array should match the original record structure */ - self::assertSame([ - [ - 'id' => 'SHP-001', - 'status' => 'shipped', - 'carrier' => 'DHL', - 'created_at' => '2026-01-10T08:00:00+00:00', - 'customer_id' => 'C-100', - ], - ], $actual); + /** @Then the result should contain the filtered, mapped and sorted values */ + self::assertSame([30, 40, 50], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testMapWhenClosureBackedTypedCarriersThenTypeIsPreserved(): void + public function testCreateFromWhenGeneratorIsConsumedTwiceThenAllElementsRemainAvailable(): void { - /** @Given a closure-backed Carriers collection */ - $carriers = Carriers::createFromClosure(factory: static function (): array { - return ['dhl', 'fedex', 'ups']; + /** @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); + + /** @And consuming the collection via count */ + $count = $collection->count(); + + /** @Then the count should be 3 */ + self::assertSame(3, $count); + + /** @And a subsequent toArray should still return all elements */ + self::assertSame([1, 2, 3], $collection->toArray()); + } + + public function testFindByWhenMultiplePredicatesThenFirstMatchAcrossPredicatesIsReturned(): 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 testChainedOperationsWhenObjectElementsThenPipelineProducesExpectedResult(): 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 and sort */ + $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) + ); + + /** @And accumulating the total discounted value via each */ + $actual->each(actions: function (Amount $amount) use (&$totalDiscounted): void { + $totalDiscounted += $amount->value; }); - /** @When mapping to uppercase */ - $actual = $carriers->map( - transformations: static fn(string $name): string => strtoupper($name) - ); + /** @Then the final collection should contain exactly three elements */ + self::assertCount(3, $actual); - /** @Then the result should still be an instance of Carriers */ - self::assertInstanceOf(Carriers::class, $actual); + /** @And the total discounted value should be 450 */ + self::assertSame(450.00, $totalDiscounted); - /** @And the carriers should be uppercased */ - self::assertSame(['DHL', 'FEDEX', 'UPS'], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @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 testChainedOperationsWhenIntegerElementsThenPipelineProducesExpectedResult(): 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()); } public function testCreateFromAndCreateFromClosureWhenSameRecordsThenProduceIdenticalShipments(): void diff --git a/tests/LazyCollectionTest.php b/tests/LazyCollectionTest.php index 8766b87..da7d965 100644 --- a/tests/LazyCollectionTest.php +++ b/tests/LazyCollectionTest.php @@ -15,19 +15,31 @@ final class LazyCollectionTest extends TestCase { - public function testCreateLazyFromWhenIntegerElementsThenHoldsAllElements(): void + public function testFindByWhenNoPredicatesThenReturnsNull(): void { - /** @Given a set of integer elements */ - $elements = [1, 2, 3]; + /** @Given a lazy collection with truthy and falsy values */ + $collection = Collection::createLazyFrom(elements: [0, 1, 2]); - /** @When creating a lazy collection from those elements */ - $collection = Collection::createLazyFrom(elements: $elements); + /** @When finding without predicates */ + $actual = $collection->findBy(); - /** @Then the collection should contain all three elements */ - self::assertSame(3, $collection->count()); + /** @Then it should return null */ + self::assertNull($actual); + } - /** @And the array should match the original elements */ - self::assertSame([1, 2, 3], $collection->toArray()); + public function testSliceWhenLengthIsZeroThenResultIsEmpty(): 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 testCreateLazyFromEmptyThenCollectionIsEmpty(): void @@ -42,68 +54,31 @@ public function testCreateLazyFromEmptyThenCollectionIsEmpty(): void self::assertSame(0, $collection->count()); } - public function testCreateLazyFromWhenGeneratorSourceThenHoldsAllElements(): 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 testAddWhenElementsAppendedThenNewCollectionContainsAllInOrder(): void + public function testFindByWhenNoElementMatchesThenReturnsNull(): void { - /** @Given a lazy collection with three elements */ + /** @Given a lazy collection of integers */ $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)); + /** @When finding an element greater than 100 */ + $actual = $collection->findBy(predicates: static fn(int $value): bool => $value > 100); - /** @And the original collection should remain unchanged */ - self::assertSame(3, $collection->count()); + /** @Then it should return null */ + self::assertNull($actual); } - public function testMergeWhenTwoCollectionsThenElementsAreCombinedInOrder(): void + public function testAddWhenElementAddedThenOriginalIsUnchanged(): 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)); - } + /** @Given a lazy collection with three elements */ + $original = Collection::createLazyFrom(elements: [1, 2, 3]); - public function testContainsWhenElementIsPresentThenReturnsTrue(): void - { - /** @Given a lazy collection with integers */ - $collection = Collection::createLazyFrom(elements: [1, 2, 3]); + /** @When adding a new element */ + $modified = $original->add(4); - /** @When checking for an element that exists */ - $actual = $collection->contains(element: 2); + /** @Then the original collection should remain unchanged */ + self::assertSame(3, $original->count()); - /** @Then it should return true */ - self::assertTrue($actual); + /** @And the new collection should have four elements */ + self::assertSame(4, $modified->count()); } public function testContainsWhenElementIsAbsentThenReturnsFalse(): void @@ -118,110 +93,61 @@ public function testContainsWhenElementIsAbsentThenReturnsFalse(): void self::assertFalse($actual); } - public function testContainsWhenEquivalentObjectIsPresentThenReturnsTrue(): void + public function testContainsWhenElementIsPresentThenReturnsTrue(): 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) - ]); + /** @Given a lazy collection with integers */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3]); - /** @When checking if the collection contains an equivalent Amount */ - $actual = $collection->contains(element: $target); + /** @When checking for an element that exists */ + $actual = $collection->contains(element: 2); /** @Then it should return true */ self::assertTrue($actual); } - public function testContainsWhenObjectSoughtInScalarCollectionThenReturnsFalse(): 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 testContainsWhenScalarSoughtInObjectCollectionThenReturnsFalse(): 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 testCountWhenCollectionHasElementsThenReturnsElementCount(): 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 testFindByWhenElementMatchesPredicateThenFirstMatchIsReturned(): void + public function testIsEmptyWhenCollectionIsEmptyThenReturnsTrue(): 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); + /** @Given an empty lazy collection */ + $empty = Collection::createLazyFromEmpty(); - /** @Then it should return 4 */ - self::assertSame(4, $actual); + /** @Then the empty collection should return true */ + self::assertTrue($empty->isEmpty()); } - public function testFindByWhenMultiplePredicatesThenFirstMatchAcrossPredicatesIsReturned(): void + public function testToJsonWhenKeysPreservedThenReturnsJsonObject(): 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; + /** @Given a lazy collection with string keys */ + $collection = Collection::createLazyFrom(elements: ['x' => 1, 'y' => 2]); - $actual = $collection->findBy($matchTen, $matchThree); + /** @When converting to JSON without arguments */ + $actual = $collection->toJson(); - /** @Then it should return the first element matching any predicate */ - self::assertSame(3, $actual); + /** @Then the result should preserve keys as a JSON object */ + self::assertSame('{"x":1,"y":2}', $actual); } - public function testFindByWhenNoPredicatesThenReturnsNull(): void + public function testGetByWhenIndexExistsThenReturnsElementAtIndex(): void { - /** @Given a lazy collection with truthy and falsy values */ - $collection = Collection::createLazyFrom(elements: [0, 1, 2]); + /** @Given a lazy collection with three elements */ + $collection = Collection::createLazyFrom(elements: ['a', 'b', 'c']); - /** @When finding without predicates */ - $actual = $collection->findBy(); + /** @When retrieving the element at index 1 */ + $actual = $collection->getBy(index: 1); - /** @Then it should return null */ - self::assertNull($actual); + /** @Then it should return 'b' */ + self::assertSame('b', $actual); } - public function testFindByWhenNoElementMatchesThenReturnsNull(): void + public function testToJsonWhenIntegerElementsThenReturnsJsonArray(): 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); + /** @When converting to JSON */ + $actual = $collection->toJson(); - /** @Then it should return null */ - self::assertNull($actual); + /** @Then the result should be a valid JSON array */ + self::assertSame('[1,2,3]', $actual); } public function testEachWhenActionAppliedThenEveryElementIsVisited(): void @@ -241,439 +167,478 @@ public function testEachWhenActionAppliedThenEveryElementIsVisited(): void self::assertSame(6, $sum); } - public function testEqualsWhenCollectionsHaveIdenticalElementsThenReturnsTrue(): void + public function testFilterWhenNoPredicateThenFalsyValuesAreRemoved(): 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]); + /** @Given a lazy collection with falsy and truthy values */ + $collection = Collection::createLazyFrom(elements: [0, '', null, false, 1, 'hello', 2]); - /** @When comparing them for equality */ - $actual = $first->equals(other: $second); + /** @When filtering without a predicate */ + $actual = $collection->filter(); - /** @Then they should be equal */ - self::assertTrue($actual); + /** @Then only truthy values should remain */ + self::assertSame([1, 'hello', 2], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testEqualsWhenCollectionsHaveDifferentElementsThenReturnsFalse(): void + public function testToArrayWhenKeysDiscardedThenValuesAreReindexed(): 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]); + /** @Given a lazy collection with non-sequential keys */ + $collection = Collection::createLazyFrom(elements: [0 => 'a', 2 => 'b', 5 => 'c']); - /** @When comparing them for equality */ - $actual = $first->equals(other: $second); + /** @When converting to array discarding keys */ + $actual = $collection->toArray(keyPreservation: KeyPreservation::DISCARD); - /** @Then they should not be equal */ - self::assertFalse($actual); + /** @Then the keys should be re-indexed from 0 */ + self::assertSame(['a', 'b', 'c'], $actual); } - public function testEqualsWhenCollectionsHaveDifferentSizesThenReturnsFalseBothWays(): void + public function testIsEmptyWhenCollectionIsNotEmptyThenReturnsFalse(): void { - /** @Given a first lazy collection with three elements */ - $first = Collection::createLazyFrom(elements: [1, 2, 3]); + /** @Given a non-empty lazy collection */ + $nonEmpty = Collection::createLazyFrom(elements: [1]); - /** @And a second lazy collection with two elements */ - $second = Collection::createLazyFrom(elements: [1, 2]); + /** @Then the non-empty collection should return false */ + self::assertFalse($nonEmpty->isEmpty()); + } - /** @When comparing first equals second */ - $firstEqualsSecond = $first->equals(other: $second); + public function testRemoveAllWhenNoPredicateThenCollectionIsEmptied(): void + { + /** @Given a lazy collection of integers */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3]); - /** @And comparing second equals first */ - $secondEqualsFirst = $second->equals(other: $first); - - /** @Then the first comparison should return false */ - self::assertFalse($firstEqualsSecond); + /** @When removing all without a predicate */ + $actual = $collection->removeAll(); - /** @And the second comparison should return false */ - self::assertFalse($secondEqualsFirst); + /** @Then the collection should be empty */ + self::assertSame(0, $actual->count()); } - public function testEqualsWhenLongerCollectionSharesPrefixThenReturnsFalse(): void + public function testLastWhenCollectionIsEmptyThenReturnsDefaultValue(): 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]); + /** @Given an empty lazy collection */ + $collection = Collection::createLazyFromEmpty(); - /** @When comparing them for equality */ - $actual = $first->equals(other: $second); + /** @When retrieving the last element with a default */ + $actual = $collection->last(defaultValueIfNotFound: 'fallback'); - /** @Then they should not be equal */ - self::assertFalse($actual); + /** @Then it should return the default value */ + self::assertSame('fallback', $actual); } - public function testEqualsWhenTrailingNullExtendsCollectionThenReturnsFalse(): void + public function testMapWhenStringKeyedCollectionThenKeysArePreserved(): 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]); + /** @Given a lazy collection with string keys */ + $collection = Collection::createLazyFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); - /** @When comparing them for equality */ - $actual = $first->equals(other: $second); + /** @When transforming each element */ + $actual = $collection->map(transformations: static fn(int $value): int => ($value * 10)); - /** @Then they should not be equal */ - self::assertFalse($actual); + /** @Then the keys should be preserved */ + self::assertSame(['a' => 10, 'b' => 20, 'c' => 30], $actual->toArray()); } - public function testRemoveWhenValueHasDuplicatesThenAllOccurrencesAreRemoved(): void + public function testSliceWhenNegativeLengthThenRemainingCountIsExact(): void { - /** @Given a lazy collection with duplicate elements */ - $collection = Collection::createLazyFrom(elements: [1, 2, 3, 2, 4]); + /** @Given a lazy collection with six elements */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5, 6]); - /** @When removing the value 2 */ - $actual = $collection->remove(element: 2); + /** @When slicing from offset 0 with length -3 (exclude last 3) */ + $actual = $collection->slice(offset: 0, length: -3); - /** @Then all occurrences of 2 should be removed */ - self::assertSame([1, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @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 testRemoveWhenScalarRemovedFromObjectCollectionThenNothingIsRemoved(): void + public function testToArrayWhenNonSequentialKeysThenKeysArePreserved(): 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) - ]); + /** @Given a lazy collection with non-sequential keys */ + $collection = Collection::createLazyFrom(elements: [0 => 'a', 2 => 'b', 5 => 'c']); - /** @When removing a scalar value */ - $actual = $collection->remove(element: 50.00); + /** @When converting to array preserving keys */ + $actual = $collection->toArray(); - /** @Then no elements should be removed */ - self::assertSame(2, $actual->count()); + /** @Then the keys should be preserved */ + self::assertSame([0 => 'a', 2 => 'b', 5 => 'c'], $actual); } - public function testRemoveWhenStringKeyedCollectionThenRemainingKeysArePreserved(): void + public function testFirstWhenCollectionIsEmptyThenReturnsDefaultValue(): void { - /** @Given a lazy collection with string keys */ - $collection = Collection::createLazyFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); + /** @Given an empty lazy collection */ + $collection = Collection::createLazyFromEmpty(); - /** @When removing the value 2 */ - $actual = $collection->remove(element: 2); + /** @When retrieving the first element with a default */ + $actual = $collection->first(defaultValueIfNotFound: 'fallback'); - /** @Then the remaining keys should be preserved */ - self::assertSame(['a' => 1, 'c' => 3], $actual->toArray()); + /** @Then it should return the default value */ + self::assertSame('fallback', $actual); } - public function testRemoveAllWhenPredicateGivenThenMatchingElementsAreRemoved(): void + public function testSortWhenDefaultKeyOrderThenKeysAreSortedAscending(): void { - /** @Given a lazy collection of integers */ - $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5]); + /** @Given a lazy collection with unordered string keys */ + $collection = Collection::createLazyFrom(elements: ['c' => 3, 'a' => 1, 'b' => 2]); - /** @When removing all elements greater than 3 */ - $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 3); + /** @When sorting by ascending key */ + $actual = $collection->sort(); - /** @Then only elements 1, 2, 3 should remain */ - self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then the keys should be in ascending order */ + self::assertSame(['a' => 1, 'b' => 2, 'c' => 3], $actual->toArray()); } - public function testRemoveAllWhenNoPredicateThenCollectionIsEmptied(): void + public function testGetByWhenIndexIsOutOfBoundsThenReturnsDefaultValue(): void { - /** @Given a lazy collection of integers */ - $collection = Collection::createLazyFrom(elements: [1, 2, 3]); + /** @Given a lazy collection with three elements */ + $collection = Collection::createLazyFrom(elements: ['a', 'b', 'c']); - /** @When removing all without a predicate */ - $actual = $collection->removeAll(); + /** @When retrieving an element at an index that does not exist */ + $actual = $collection->getBy(index: 99, defaultValueIfNotFound: 'missing'); - /** @Then the collection should be empty */ - self::assertSame(0, $actual->count()); + /** @Then it should return the default value */ + self::assertSame('missing', $actual); } - public function testRemoveAllWhenFirstElementDoesNotMatchThenOnlyMatchingAreRemoved(): void + public function testLastWhenCollectionIsNotEmptyThenReturnsLastElement(): void { - /** @Given a lazy collection where the first element does not match the predicate */ - $collection = Collection::createLazyFrom(elements: [1, 10, 2, 20, 3]); + /** @Given a lazy collection with three elements */ + $collection = Collection::createLazyFrom(elements: [10, 20, 30]); - /** @When removing all elements greater than 5 */ - $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 5); + /** @When retrieving the last element */ + $actual = $collection->last(); - /** @Then elements 1, 2, 3 should remain */ - self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then it should return 30 */ + self::assertSame(30, $actual); } - public function testRemoveAllWhenStringKeyedCollectionThenRemainingKeysArePreserved(): void + public function testLastWhenLastElementIsNullThenReturnsNullNotDefault(): void { - /** @Given a lazy collection with string keys */ - $collection = Collection::createLazyFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); + /** @Given a lazy collection where the last element is null */ + $collection = Collection::createLazyFrom(elements: [1, 2, null]); - /** @When removing elements greater than 2 */ - $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 2); + /** @When retrieving the last element with a default */ + $actual = $collection->last(defaultValueIfNotFound: 'fallback'); - /** @Then the remaining keys should be preserved */ - self::assertSame(['a' => 1, 'b' => 2], $actual->toArray()); + /** @Then it should return null, not the default */ + self::assertNull($actual); } - public function testFirstWhenCollectionIsNotEmptyThenReturnsFirstElement(): void + public function testReduceWhenClosureBackedObjectsThenAmountsAreSummed(): void { - /** @Given a lazy collection with three elements */ - $collection = Collection::createLazyFrom(elements: [10, 20, 30]); + /** @Given a closure that yields Amount objects */ + $collection = Collection::createLazyFromClosure(factory: static function (): Generator { + yield new Amount(value: 100.00, currency: Currency::USD); + yield new Amount(value: 200.00, currency: Currency::USD); + yield new Amount(value: 300.00, currency: Currency::USD); + }); - /** @When retrieving the first element */ - $actual = $collection->first(); + /** @When reducing to sum all amounts */ + $total = $collection->reduce( + accumulator: static fn(float $carry, Amount $amount): float => ($carry + $amount->value), + initial: 0.0 + ); - /** @Then it should return 10 */ - self::assertSame(10, $actual); + /** @Then the total should be 600 */ + self::assertSame(600.00, $total); } - public function testFirstWhenCollectionIsEmptyThenReturnsDefaultValue(): void + public function testSliceWhenStringKeyedCollectionThenKeysArePreserved(): void { - /** @Given an empty lazy collection */ - $collection = Collection::createLazyFromEmpty(); + /** @Given a lazy collection with string keys */ + $collection = Collection::createLazyFrom(elements: ['a' => 10, 'b' => 20, 'c' => 30, 'd' => 40]); - /** @When retrieving the first element with a default */ - $actual = $collection->first(defaultValueIfNotFound: 'fallback'); + /** @When slicing from offset 1 with length 2 */ + $actual = $collection->slice(offset: 1, length: 2); - /** @Then it should return the default value */ - self::assertSame('fallback', $actual); + /** @Then the keys should be preserved */ + self::assertSame(['b' => 20, 'c' => 30], $actual->toArray()); } - public function testFirstWhenCollectionIsEmptyWithoutDefaultThenReturnsNull(): void + public function testJoinToStringWhenSingleElementThenSeparatorIsOmitted(): void { - /** @Given an empty lazy collection */ - $collection = Collection::createLazyFromEmpty(); + /** @Given a lazy collection with a single integer */ + $collection = Collection::createLazyFrom(elements: [42]); - /** @When retrieving the first element without a default */ - $actual = $collection->first(); + /** @When joining to string */ + $actual = $collection->joinToString(separator: ', '); - /** @Then it should return null */ - self::assertNull($actual); + /** @Then the result should be a string */ + self::assertSame('42', $actual); } - public function testFirstWhenFirstElementIsNullThenReturnsNullNotDefault(): void + public function testMapWhenMultipleTransformationsThenAppliedInSequence(): void { - /** @Given a lazy collection where the first element is null */ - $collection = Collection::createLazyFrom(elements: [null, 1, 2]); + /** @Given a lazy collection of integers */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3]); - /** @When retrieving the first element with a default */ - $actual = $collection->first(defaultValueIfNotFound: 'fallback'); + /** @When applying two transformations: increment, then double */ + $actual = $collection->map( + static fn(int $value): int => ($value + 1), + static fn(int $value): int => ($value * 2) + ); - /** @Then it should return null, not the default */ - self::assertNull($actual); + /** @Then both transformations should be applied in order */ + self::assertSame([4, 6, 8], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testFlattenWhenNestedArraysThenElementsAreLiftedOneLevel(): void + public function testSliceWhenOffsetAndLengthGivenThenSubrangeIsReturned(): void { - /** @Given a lazy collection with nested arrays */ - $collection = Collection::createLazyFrom(elements: [[1, 2], [3, 4], 5]); + /** @Given a lazy collection of five elements */ + $collection = Collection::createLazyFrom(elements: [10, 20, 30, 40, 50]); - /** @When flattening by one level */ - $actual = $collection->flatten(); + /** @When slicing from offset 1 with length 2 */ + $actual = $collection->slice(offset: 1, length: 2); - /** @Then all elements should be at the top level */ - self::assertSame([1, 2, 3, 4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then the result should contain elements 20 and 30 */ + self::assertSame([20, 30], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testGetByWhenIndexExistsThenReturnsElementAtIndex(): void + public function testSliceWhenOffsetWithoutLengthThenRemainderIsReturned(): void { - /** @Given a lazy collection with three elements */ - $collection = Collection::createLazyFrom(elements: ['a', 'b', 'c']); + /** @Given a lazy collection of five elements */ + $collection = Collection::createLazyFrom(elements: [10, 20, 30, 40, 50]); - /** @When retrieving the element at index 1 */ - $actual = $collection->getBy(index: 1); + /** @When slicing from offset 2 without specifying length */ + $actual = $collection->slice(offset: 2); - /** @Then it should return 'b' */ - self::assertSame('b', $actual); + /** @Then the result should contain all elements from index 2 onward */ + self::assertSame([30, 40, 50], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testGetByWhenIndexIsOutOfBoundsThenReturnsDefaultValue(): void + public function testContainsWhenEquivalentObjectIsPresentThenReturnsTrue(): void { - /** @Given a lazy collection with three elements */ - $collection = Collection::createLazyFrom(elements: ['a', 'b', 'c']); + /** @Given an Amount object to search for */ + $target = new Amount(value: 100.00, currency: Currency::USD); - /** @When retrieving an element at an index that does not exist */ - $actual = $collection->getBy(index: 99, defaultValueIfNotFound: 'missing'); + /** @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) + ]); - /** @Then it should return the default value */ - self::assertSame('missing', $actual); + /** @When checking if the collection contains an equivalent Amount */ + $actual = $collection->contains(element: $target); + + /** @Then it should return true */ + self::assertTrue($actual); } - public function testGroupByWhenClassifierGivenThenElementsAreGroupedByClassifier(): void + public function testFirstWhenCollectionIsNotEmptyThenReturnsFirstElement(): 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' - ); + /** @Given a lazy collection with three elements */ + $collection = Collection::createLazyFrom(elements: [10, 20, 30]); - /** @Then the odd group should contain 1, 3, 5 */ - $groups = $actual->toArray(); - self::assertSame([1, 3, 5], $groups['odd']); + /** @When retrieving the first element */ + $actual = $collection->first(); - /** @And the even group should contain 2, 4, 6 */ - self::assertSame([2, 4, 6], $groups['even']); + /** @Then it should return 10 */ + self::assertSame(10, $actual); } - public function testIsEmptyWhenCollectionIsEmptyThenReturnsTrue(): void + public function testFirstWhenFirstElementIsNullThenReturnsNullNotDefault(): void { - /** @Given an empty lazy collection */ - $empty = Collection::createLazyFromEmpty(); + /** @Given a lazy collection where the first element is null */ + $collection = Collection::createLazyFrom(elements: [null, 1, 2]); - /** @Then the empty collection should return true */ - self::assertTrue($empty->isEmpty()); + /** @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 testIsEmptyWhenCollectionIsNotEmptyThenReturnsFalse(): void + public function testFlattenWhenNestedArraysThenElementsAreLiftedOneLevel(): void { - /** @Given a non-empty lazy collection */ - $nonEmpty = Collection::createLazyFrom(elements: [1]); + /** @Given a lazy collection with nested arrays */ + $collection = Collection::createLazyFrom(elements: [[1, 2], [3, 4], 5]); - /** @Then the non-empty collection should return false */ - self::assertFalse($nonEmpty->isEmpty()); + /** @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 testJoinToStringWhenStringElementsThenValuesAreJoinedWithSeparator(): void + public function testAddWhenClosureBackedCollectionThenElementsAreAppended(): void { - /** @Given a lazy collection of strings */ - $collection = Collection::createLazyFrom(elements: ['a', 'b', 'c']); + /** @Given a closure-backed collection */ + $collection = Collection::createLazyFromClosure(factory: static function (): Generator { + yield 1; + yield 2; + }); - /** @When joining with a comma separator */ - $actual = $collection->joinToString(separator: ', '); + /** @When adding elements */ + $actual = $collection->add(3, 4); - /** @Then the result should be "a, b, c" */ - self::assertSame('a, b, c', $actual); + /** @Then all elements should be present */ + self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testJoinToStringWhenIntegerElementsThenValuesAreJoinedWithSeparator(): void + public function testCountWhenCollectionHasElementsThenReturnsElementCount(): void { - /** @Given a lazy collection of integers */ - $collection = Collection::createLazyFrom(elements: [1, 2, 3]); + /** @Given a lazy collection with five elements */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5]); - /** @When joining with a comma separator */ - $actual = $collection->joinToString(separator: ', '); + /** @When counting the elements */ + $actual = $collection->count(); - /** @Then the result should be "1, 2, 3" */ - self::assertSame('1, 2, 3', $actual); + /** @Then it should return 5 */ + self::assertSame(5, $actual); } - public function testJoinToStringWhenSingleElementThenSeparatorIsOmitted(): void + public function testCreateLazyFromWhenGeneratorSourceThenHoldsAllElements(): void { - /** @Given a lazy collection with a single integer */ - $collection = Collection::createLazyFrom(elements: [42]); + /** @Given a generator that yields three elements */ + $generator = (static function (): Generator { + yield 1; + yield 2; + yield 3; + })(); - /** @When joining to string */ - $actual = $collection->joinToString(separator: ', '); + /** @When creating a lazy collection from the generator */ + $collection = Collection::createLazyFrom(elements: $generator); - /** @Then the result should be a string */ - self::assertSame('42', $actual); + /** @Then the collection should contain all three elements */ + self::assertSame(3, $collection->count()); } - public function testFilterWhenPredicateGivenThenOnlyMatchingElementsRemain(): void + public function testCreateLazyFromWhenIntegerElementsThenHoldsAllElements(): void { - /** @Given a lazy collection of integers */ - $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5]); + /** @Given a set of integer elements */ + $elements = [1, 2, 3]; - /** @When keeping only elements greater than 3 */ - $actual = $collection->filter(predicates: static fn(int $value): bool => $value > 3); + /** @When creating a lazy collection from those elements */ + $collection = Collection::createLazyFrom(elements: $elements); - /** @Then only 4 and 5 should remain */ - self::assertSame([4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then the collection should contain all three elements */ + self::assertSame(3, $collection->count()); + + /** @And the array should match the original elements */ + self::assertSame([1, 2, 3], $collection->toArray()); } - public function testFilterWhenSinglePredicateMatchesFalsyValueThenItIsKept(): void + public function testMergeWhenTwoCollectionsThenElementsAreCombinedInOrder(): void { - /** @Given a lazy collection containing a falsy value and a non-matching value */ - $collection = Collection::createLazyFrom(elements: [0, 1, 2, 9]); + /** @Given a first lazy collection */ + $first = Collection::createLazyFrom(elements: [1, 2]); - /** @When filtering with a single predicate that matches values below five */ - $actual = $collection->filter(predicates: static fn(int $value): bool => $value < 5); + /** @And a second lazy collection */ + $second = Collection::createLazyFrom(elements: [3, 4]); - /** @Then the falsy value matched by the predicate is kept and the non-matching value is dropped */ - self::assertSame([0, 1, 2], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @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 testFilterWhenNoPredicateThenFalsyValuesAreRemoved(): void + public function testReduceWhenAppliedOverChainedPipelineThenSumIsReturned(): void { - /** @Given a lazy collection with falsy and truthy values */ - $collection = Collection::createLazyFrom(elements: [0, '', null, false, 1, 'hello', 2]); + /** @Given a lazy collection of integers from 1 to 100 */ + $collection = Collection::createLazyFrom(elements: range(1, 100)); - /** @When filtering without a predicate */ - $actual = $collection->filter(); + /** @And the collection is filtered to even numbers, squared, and sorted descending */ + $pipeline = $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 only truthy values should remain */ - self::assertSame([1, 'hello', 2], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @When reducing to calculate the sum of all squared even numbers */ + $sum = $pipeline->reduce( + accumulator: static fn(int $carry, int $value): int => ($carry + $value), + initial: 0 + ); + + /** @Then the sum should be 171700 */ + self::assertSame(171700, $sum); } - public function testFilterWhenExplicitNullPredicateThenFalsyValuesAreRemoved(): void + public function testSortWhenComparatorDiffersFromDefaultThenOrdersDiverge(): void { - /** @Given a lazy collection with falsy and truthy values */ - $collection = Collection::createLazyFrom(elements: [0, '', 1, 'hello', 2]); + /** @Given a lazy collection where alphabetical and length order diverge */ + $collection = Collection::createLazyFrom(elements: ['zz', 'a', 'bbb']); - /** @When filtering with an explicit null predicate */ - $actual = $collection->filter(null); + /** @When sorting ascending by length */ + $byLength = $collection->sort( + order: Order::ASCENDING_VALUE, + comparator: static fn(string $first, string $second): int => (strlen($first) <=> strlen($second)) + ); - /** @Then only truthy values should remain */ - self::assertSame([1, 'hello', 2], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @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 testFilterWhenStringKeyedCollectionThenRemainingKeysArePreserved(): void + public function testSortWhenDescendingKeyOrderThenKeysAreSortedDescending(): void { - /** @Given a lazy collection with string keys */ + /** @Given a lazy collection with ordered 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); + /** @When sorting by descending key */ + $actual = $collection->sort(order: Order::DESCENDING_KEY); - /** @Then the remaining keys should be preserved */ - self::assertSame(['b' => 2, 'c' => 3], $actual->toArray()); + /** @Then the keys should be in descending order */ + self::assertSame(['c' => 3, 'b' => 2, 'a' => 1], $actual->toArray()); } - public function testFilterWhenMultiplePredicatesThenOnlyElementsMatchingAllRemain(): void + public function testToJsonWhenKeysDiscardedThenReturnsSequentialJsonArray(): void { - /** @Given a lazy collection of integers */ - $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + /** @Given a lazy collection with string keys */ + $collection = Collection::createLazyFrom(elements: ['x' => 1, 'y' => 2]); - /** @When filtering with two predicates: greater than 3 and even */ - $actual = $collection->filter( - static fn(int $value): bool => $value > 3, - static fn(int $value): bool => ($value % 2) === 0 - ); + /** @When converting to JSON discarding keys */ + $actual = $collection->toJson(keyPreservation: KeyPreservation::DISCARD); - /** @Then only elements satisfying both predicates should remain */ - self::assertSame([4, 6, 8, 10], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then the result should be a sequential JSON array */ + self::assertSame('[1,2]', $actual); } - public function testLastWhenCollectionIsNotEmptyThenReturnsLastElement(): void + public function testEqualsWhenLongerCollectionSharesPrefixThenReturnsFalse(): void { - /** @Given a lazy collection with three elements */ - $collection = Collection::createLazyFrom(elements: [10, 20, 30]); + /** @Given a first lazy collection with four elements */ + $first = Collection::createLazyFrom(elements: [1, 2, 3, 4]); - /** @When retrieving the last element */ - $actual = $collection->last(); + /** @And a second lazy collection with three elements */ + $second = Collection::createLazyFrom(elements: [1, 2, 3]); - /** @Then it should return 30 */ - self::assertSame(30, $actual); + /** @When comparing them for equality */ + $actual = $first->equals(other: $second); + + /** @Then they should not be equal */ + self::assertFalse($actual); } - public function testLastWhenCollectionIsEmptyThenReturnsDefaultValue(): void + public function testFilterWhenPredicateGivenThenOnlyMatchingElementsRemain(): void { - /** @Given an empty lazy collection */ - $collection = Collection::createLazyFromEmpty(); + /** @Given a lazy collection of integers */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5]); - /** @When retrieving the last element with a default */ - $actual = $collection->last(defaultValueIfNotFound: 'fallback'); + /** @When keeping only elements greater than 3 */ + $actual = $collection->filter(predicates: static fn(int $value): bool => $value > 3); - /** @Then it should return the default value */ - self::assertSame('fallback', $actual); + /** @Then only 4 and 5 should remain */ + self::assertSame([4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testLastWhenLastElementIsNullThenReturnsNullNotDefault(): void + public function testFilterWhenSinglePredicateMatchesFalsyValueThenItIsKept(): void { - /** @Given a lazy collection where the last element is null */ - $collection = Collection::createLazyFrom(elements: [1, 2, null]); + /** @Given a lazy collection containing a falsy value and a non-matching value */ + $collection = Collection::createLazyFrom(elements: [0, 1, 2, 9]); - /** @When retrieving the last element with a default */ - $actual = $collection->last(defaultValueIfNotFound: 'fallback'); + /** @When filtering with a single predicate that matches values below five */ + $actual = $collection->filter(predicates: static fn(int $value): bool => $value < 5); - /** @Then it should return null, not the default */ - self::assertNull($actual); + /** @Then the falsy value matched by the predicate is kept and the non-matching value is dropped */ + self::assertSame([0, 1, 2], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } public function testMapWhenTransformationGivenThenEachElementIsTransformed(): void @@ -688,31 +653,98 @@ public function testMapWhenTransformationGivenThenEachElementIsTransformed(): vo self::assertSame([10, 20, 30], $actual->toArray()); } - public function testMapWhenStringKeyedCollectionThenKeysArePreserved(): void + public function testSliceWhenNegativeLengthThenTrailingElementsAreExcluded(): 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 testSliceWhenOffsetAndNegativeLengthThenSubrangeIsReturned(): 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 testEqualsWhenTrailingNullExtendsCollectionThenReturnsFalse(): 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 testFirstWhenCollectionIsEmptyWithoutDefaultThenReturnsNull(): 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 testSliceWhenNegativeLengthOnStringKeysThenKeysArePreserved(): void { /** @Given a lazy collection with string keys */ - $collection = Collection::createLazyFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); + $collection = Collection::createLazyFrom(elements: ['a' => 10, 'b' => 20, 'c' => 30, 'd' => 40]); - /** @When transforming each element */ - $actual = $collection->map(transformations: static fn(int $value): int => ($value * 10)); + /** @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, 'c' => 30], $actual->toArray()); + self::assertSame(['a' => 10, 'b' => 20], $actual->toArray()); } - public function testMapWhenMultipleTransformationsThenAppliedInSequence(): void + public function testContainsWhenClosureBackedCollectionThenDetectsMembership(): void { - /** @Given a lazy collection of integers */ - $collection = Collection::createLazyFrom(elements: [1, 2, 3]); + /** @Given a closure-backed collection */ + $collection = Collection::createLazyFromClosure(factory: static function (): Generator { + yield 'alpha'; + yield 'beta'; + yield 'gamma'; + }); - /** @When applying two transformations: increment, then double */ - $actual = $collection->map( - static fn(int $value): int => ($value + 1), - static fn(int $value): int => ($value * 2) - ); + /** @When checking if the collection contains an existing element */ + $containsBeta = $collection->contains(element: 'beta'); - /** @Then both transformations should be applied in order */ - self::assertSame([4, 6, 8], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then it should return true */ + self::assertTrue($containsBeta); + + /** @And checking for a non-existing element should return false */ + self::assertFalse($collection->contains(element: 'delta')); + } + + public function testFilterWhenExplicitNullPredicateThenFalsyValuesAreRemoved(): 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 testMapWhenNoTransformationsThenElementsAreReturnedUnchanged(): void @@ -727,402 +759,396 @@ public function testMapWhenNoTransformationsThenElementsAreReturnedUnchanged(): self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testReduceWhenAccumulatorGivenThenElementsAreAccumulatedToSingleValue(): void + public function testRemoveWhenValueHasDuplicatesThenAllOccurrencesAreRemoved(): void { - /** @Given a lazy collection of integers */ - $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4]); + /** @Given a lazy collection with duplicate elements */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3, 2, 4]); - /** @When reducing to calculate the sum */ - $actual = $collection->reduce( - accumulator: static fn(int $carry, int $value): int => ($carry + $value), - initial: 0 - ); + /** @When removing the value 2 */ + $actual = $collection->remove(element: 2); - /** @Then the sum should be 10 */ - self::assertSame(10, $actual); + /** @Then all occurrences of 2 should be removed */ + self::assertSame([1, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testSortWhenAscendingValueOrderThenElementsAreSortedAscending(): void + public function testEqualsWhenCollectionsHaveIdenticalElementsThenReturnsTrue(): void { - /** @Given a lazy collection with unordered elements */ - $collection = Collection::createLazyFrom(elements: [3, 1, 2]); + /** @Given a first lazy collection */ + $first = Collection::createLazyFrom(elements: [1, 2, 3]); - /** @When sorting in ascending order by value */ - $actual = $collection->sort(order: Order::ASCENDING_VALUE); + /** @And a second lazy collection with the same elements */ + $second = Collection::createLazyFrom(elements: [1, 2, 3]); - /** @Then the elements should be in ascending order */ - self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @When comparing them for equality */ + $actual = $first->equals(other: $second); + + /** @Then they should be equal */ + self::assertTrue($actual); } - public function testSortWhenDescendingValueOrderThenElementsAreSortedDescending(): void + public function testFindByWhenElementMatchesPredicateThenFirstMatchIsReturned(): void { - /** @Given a lazy collection with ordered elements */ - $collection = Collection::createLazyFrom(elements: [1, 2, 3]); + /** @Given a lazy collection of integers */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5]); - /** @When sorting in descending order by value */ - $actual = $collection->sort(order: Order::DESCENDING_VALUE); + /** @When finding the first element greater than 3 */ + $actual = $collection->findBy(predicates: static fn(int $value): bool => $value > 3); - /** @Then the elements should be in descending order */ - self::assertSame([3, 2, 1], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then it should return 4 */ + self::assertSame(4, $actual); } - public function testSortWhenDefaultKeyOrderThenKeysAreSortedAscending(): void + public function testGetByWhenClosureBackedCollectionThenReturnsElementAtIndex(): void { - /** @Given a lazy collection with unordered string keys */ - $collection = Collection::createLazyFrom(elements: ['c' => 3, 'a' => 1, 'b' => 2]); + /** @Given a closure-backed collection */ + $collection = Collection::createLazyFromClosure(factory: static function (): Generator { + yield 'alpha'; + yield 'beta'; + yield 'gamma'; + }); - /** @When sorting by ascending key */ - $actual = $collection->sort(); + /** @When retrieving element at index 1 */ + $actual = $collection->getBy(index: 1); - /** @Then the keys should be in ascending order */ - self::assertSame(['a' => 1, 'b' => 2, 'c' => 3], $actual->toArray()); + /** @Then it should return the second element */ + self::assertSame('beta', $actual); } - public function testSortWhenDescendingKeyOrderThenKeysAreSortedDescending(): void + public function testRemoveAllWhenPredicateGivenThenMatchingElementsAreRemoved(): void { - /** @Given a lazy collection with ordered string keys */ - $collection = Collection::createLazyFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); + /** @Given a lazy collection of integers */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5]); - /** @When sorting by descending key */ - $actual = $collection->sort(order: Order::DESCENDING_KEY); + /** @When removing all elements greater than 3 */ + $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 3); - /** @Then the keys should be in descending order */ - self::assertSame(['c' => 3, 'b' => 2, 'a' => 1], $actual->toArray()); + /** @Then only elements 1, 2, 3 should remain */ + self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testSortWhenAscendingValueWithoutComparatorThenDefaultComparisonIsUsed(): void + public function testSortWhenAscendingValueOrderThenElementsAreSortedAscending(): void { - /** @Given a lazy collection with unordered integers */ - $collection = Collection::createLazyFrom(elements: [3, 1, 4, 1, 5]); + /** @Given a lazy collection with unordered elements */ + $collection = Collection::createLazyFrom(elements: [3, 1, 2]); - /** @When sorting ascending by value without a custom comparator */ + /** @When sorting in ascending order by value */ $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)); + /** @Then the elements should be in ascending order */ + self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testSortWhenCustomComparatorGivenThenElementsAreOrderedByComparator(): void + public function testAddWhenElementsAppendedThenNewCollectionContainsAllInOrder(): 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) - ]); + /** @Given a lazy collection with three elements */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3]); - /** @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) - ); + /** @When adding two more elements */ + $actual = $collection->add(4, 5); - /** @Then the first element should have the lowest value */ - self::assertSame(100.00, $actual->first()->value); + /** @Then the new collection should contain five elements */ + self::assertSame(5, $actual->count()); - /** @And the last element should have the highest value */ - self::assertSame(300.00, $actual->last()->value); + /** @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 testContainsWhenObjectSoughtInScalarCollectionThenReturnsFalse(): 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 testSortWhenComparatorDiffersFromDefaultThenOrdersDiverge(): void + public function testContainsWhenScalarSoughtInObjectCollectionThenReturnsFalse(): 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); + /** @Given a lazy collection containing a stdClass object */ + $collection = Collection::createLazyFrom(elements: [new stdClass()]); - /** @Then the custom order should be by length */ - self::assertSame(['a', 'zz', 'bbb'], $byLength->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @When checking if the collection contains boolean true */ + $actual = $collection->contains(element: true); - /** @And the default order should be alphabetical */ - self::assertSame(['a', 'bbb', 'zz'], $byDefault->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then it should return false because an object is not a scalar */ + self::assertFalse($actual); } - public function testSliceWhenOffsetAndLengthGivenThenSubrangeIsReturned(): void + public function testEqualsWhenCollectionsHaveDifferentElementsThenReturnsFalse(): void { - /** @Given a lazy collection of five elements */ - $collection = Collection::createLazyFrom(elements: [10, 20, 30, 40, 50]); + /** @Given a first lazy collection */ + $first = Collection::createLazyFrom(elements: [1, 2, 3]); - /** @When slicing from offset 1 with length 2 */ - $actual = $collection->slice(offset: 1, length: 2); + /** @And a second lazy collection with different elements */ + $second = Collection::createLazyFrom(elements: [1, 2, 4]); - /** @Then the result should contain elements 20 and 30 */ - self::assertSame([20, 30], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @When comparing them for equality */ + $actual = $first->equals(other: $second); + + /** @Then they should not be equal */ + self::assertFalse($actual); } - public function testSliceWhenOffsetWithoutLengthThenRemainderIsReturned(): void + public function testSortWhenDescendingValueOrderThenElementsAreSortedDescending(): void { - /** @Given a lazy collection of five elements */ - $collection = Collection::createLazyFrom(elements: [10, 20, 30, 40, 50]); + /** @Given a lazy collection with ordered elements */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3]); - /** @When slicing from offset 2 without specifying length */ - $actual = $collection->slice(offset: 2); + /** @When sorting in descending order by value */ + $actual = $collection->sort(order: Order::DESCENDING_VALUE); - /** @Then the result should contain all elements from index 2 onward */ - self::assertSame([30, 40, 50], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then the elements should be in descending order */ + self::assertSame([3, 2, 1], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testSliceWhenStringKeyedCollectionThenKeysArePreserved(): void + public function testFilterWhenStringKeyedCollectionThenRemainingKeysArePreserved(): void { /** @Given a lazy collection with string keys */ - $collection = Collection::createLazyFrom(elements: ['a' => 10, 'b' => 20, 'c' => 30, 'd' => 40]); + $collection = Collection::createLazyFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); - /** @When slicing from offset 1 with length 2 */ - $actual = $collection->slice(offset: 1, length: 2); + /** @When filtering only elements greater than 1 */ + $actual = $collection->filter(predicates: static fn(int $value): bool => $value > 1); - /** @Then the keys should be preserved */ - self::assertSame(['b' => 20, 'c' => 30], $actual->toArray()); + /** @Then the remaining keys should be preserved */ + self::assertSame(['b' => 2, 'c' => 3], $actual->toArray()); } - public function testSliceWhenLengthIsZeroThenResultIsEmpty(): void + public function testGroupByWhenClassifierGivenThenElementsAreGroupedByClassifier(): void { - /** @Given a lazy collection with five elements */ - $collection = Collection::createLazyFrom(elements: [10, 20, 30, 40, 50]); + /** @Given a lazy collection of integers from 1 to 6 */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5, 6]); - /** @When slicing with length zero */ - $actual = $collection->slice(offset: 0, length: 0); + /** @When grouping by even and odd */ + $actual = $collection->groupBy( + classifier: static fn(int $value): string => ($value % 2) === 0 ? 'even' : 'odd' + ); - /** @Then the result should be empty */ - self::assertTrue($actual->isEmpty()); + /** @Then the odd group should contain 1, 3, 5 */ + $groups = $actual->toArray(); + self::assertSame([1, 3, 5], $groups['odd']); - /** @And the count should be zero */ - self::assertSame(0, $actual->count()); + /** @And the even group should contain 2, 4, 6 */ + self::assertSame([2, 4, 6], $groups['even']); } - public function testSliceWhenNegativeLengthThenTrailingElementsAreExcluded(): void + public function testMergeWhenClosureBackedMergedWithEagerThenElementsAreCombined(): void { - /** @Given a lazy collection with five elements */ - $collection = Collection::createLazyFrom(elements: [10, 20, 30, 40, 50]); + /** @Given a closure-backed collection */ + $closureCollection = Collection::createLazyFromClosure(factory: static function (): Generator { + yield 1; + yield 2; + }); - /** @When slicing from offset 0 with length -2 (exclude last 2) */ - $actual = $collection->slice(offset: 0, length: -2); + /** @And an eager collection */ + $eagerCollection = Collection::createFrom(elements: [3, 4]); - /** @Then the result should contain the first three elements */ - self::assertSame([10, 20, 30], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @When merging them */ + $actual = $closureCollection->merge(other: $eagerCollection); + + /** @Then the result should contain all elements */ + self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testSliceWhenOffsetAndNegativeLengthThenSubrangeIsReturned(): void + public function testRemoveWhenStringKeyedCollectionThenRemainingKeysArePreserved(): void { - /** @Given a lazy collection with five elements */ - $collection = Collection::createLazyFrom(elements: [10, 20, 30, 40, 50]); + /** @Given a lazy collection with string keys */ + $collection = Collection::createLazyFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); - /** @When slicing from offset 1 with length -2 (skip first, exclude last 2) */ - $actual = $collection->slice(offset: 1, length: -2); + /** @When removing the value 2 */ + $actual = $collection->remove(element: 2); - /** @Then the result should contain elements 20 and 30 */ - self::assertSame([20, 30], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then the remaining keys should be preserved */ + self::assertSame(['a' => 1, 'c' => 3], $actual->toArray()); } - public function testSliceWhenNegativeLengthOnStringKeysThenKeysArePreserved(): void + public function testCreateLazyFromClosureWhenGeneratorClosureThenHoldsAllElements(): void { - /** @Given a lazy collection with string keys */ - $collection = Collection::createLazyFrom(elements: ['a' => 10, 'b' => 20, 'c' => 30, 'd' => 40]); + /** @Given a closure that yields three elements */ + $factory = static function (): Generator { + yield 1; + yield 2; + yield 3; + }; - /** @When slicing from offset 0 with length -2 */ - $actual = $collection->slice(offset: 0, length: -2); + /** @When creating a lazy collection from the closure */ + $collection = Collection::createLazyFromClosure(factory: $factory); - /** @Then the keys should be preserved */ - self::assertSame(['a' => 10, 'b' => 20], $actual->toArray()); + /** @Then the collection should contain all three elements */ + self::assertSame(3, $collection->count()); + + /** @And the array should match the expected elements */ + self::assertSame([1, 2, 3], $collection->toArray()); } - public function testSliceWhenNegativeLengthThenRemainingCountIsExact(): void + public function testFilterWhenMultiplePredicatesThenOnlyElementsMatchingAllRemain(): 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); + /** @Given a lazy collection of integers */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); - /** @Then the collection should contain exactly 3 elements */ - self::assertCount(3, $actual); + /** @When filtering with two predicates: greater than 3 and even */ + $actual = $collection->filter( + static fn(int $value): bool => $value > 3, + static fn(int $value): bool => ($value % 2) === 0 + ); - /** @And the elements should be 1, 2, 3 */ - self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @Then only elements satisfying both predicates should remain */ + self::assertSame([4, 6, 8, 10], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testToArrayWhenNonSequentialKeysThenKeysArePreserved(): void + public function testJoinToStringWhenStringElementsThenValuesAreJoinedWithSeparator(): void { - /** @Given a lazy collection with non-sequential keys */ - $collection = Collection::createLazyFrom(elements: [0 => 'a', 2 => 'b', 5 => 'c']); + /** @Given a lazy collection of strings */ + $collection = Collection::createLazyFrom(elements: ['a', 'b', 'c']); - /** @When converting to array preserving keys */ - $actual = $collection->toArray(); + /** @When joining with a comma separator */ + $actual = $collection->joinToString(separator: ', '); - /** @Then the keys should be preserved */ - self::assertSame([0 => 'a', 2 => 'b', 5 => 'c'], $actual); + /** @Then the result should be "a, b, c" */ + self::assertSame('a, b, c', $actual); } - public function testToArrayWhenKeysDiscardedThenValuesAreReindexed(): void + public function testEqualsWhenCollectionsHaveDifferentSizesThenReturnsFalseBothWays(): void { - /** @Given a lazy collection with non-sequential keys */ - $collection = Collection::createLazyFrom(elements: [0 => 'a', 2 => 'b', 5 => 'c']); + /** @Given a first lazy collection with three elements */ + $first = Collection::createLazyFrom(elements: [1, 2, 3]); - /** @When converting to array discarding keys */ - $actual = $collection->toArray(keyPreservation: KeyPreservation::DISCARD); + /** @And a second lazy collection with two elements */ + $second = Collection::createLazyFrom(elements: [1, 2]); - /** @Then the keys should be re-indexed from 0 */ - self::assertSame(['a', 'b', 'c'], $actual); + /** @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 testToJsonWhenIntegerElementsThenReturnsJsonArray(): void + public function testJoinToStringWhenIntegerElementsThenValuesAreJoinedWithSeparator(): void { /** @Given a lazy collection of integers */ $collection = Collection::createLazyFrom(elements: [1, 2, 3]); - /** @When converting to JSON */ - $actual = $collection->toJson(); + /** @When joining with a comma separator */ + $actual = $collection->joinToString(separator: ', '); - /** @Then the result should be a valid JSON array */ - self::assertSame('[1,2,3]', $actual); + /** @Then the result should be "1, 2, 3" */ + self::assertSame('1, 2, 3', $actual); } - public function testToJsonWhenKeysDiscardedThenReturnsSequentialJsonArray(): void + public function testRemoveAllWhenFirstElementDoesNotMatchThenOnlyMatchingAreRemoved(): void { - /** @Given a lazy collection with string keys */ - $collection = Collection::createLazyFrom(elements: ['x' => 1, 'y' => 2]); + /** @Given a lazy collection where the first element does not match the predicate */ + $collection = Collection::createLazyFrom(elements: [1, 10, 2, 20, 3]); - /** @When converting to JSON discarding keys */ - $actual = $collection->toJson(keyPreservation: KeyPreservation::DISCARD); + /** @When removing all elements greater than 5 */ + $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 5); - /** @Then the result should be a sequential JSON array */ - self::assertSame('[1,2]', $actual); + /** @Then elements 1, 2, 3 should remain */ + self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testToJsonWhenKeysPreservedThenReturnsJsonObject(): void + public function testRemoveAllWhenStringKeyedCollectionThenRemainingKeysArePreserved(): void { /** @Given a lazy collection with string keys */ - $collection = Collection::createLazyFrom(elements: ['x' => 1, 'y' => 2]); + $collection = Collection::createLazyFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); - /** @When converting to JSON without arguments */ - $actual = $collection->toJson(); + /** @When removing elements greater than 2 */ + $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 2); - /** @Then the result should preserve keys as a JSON object */ - self::assertSame('{"x":1,"y":2}', $actual); + /** @Then the remaining keys should be preserved */ + self::assertSame(['a' => 1, 'b' => 2], $actual->toArray()); } - public function testAddWhenElementAddedThenOriginalIsUnchanged(): void + public function testRemoveWhenScalarRemovedFromObjectCollectionThenNothingIsRemoved(): void { - /** @Given a lazy collection with three elements */ - $original = Collection::createLazyFrom(elements: [1, 2, 3]); - - /** @When adding a new element */ - $modified = $original->add(4); + /** @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) + ]); - /** @Then the original collection should remain unchanged */ - self::assertSame(3, $original->count()); + /** @When removing a scalar value */ + $actual = $collection->remove(element: 50.00); - /** @And the new collection should have four elements */ - self::assertSame(4, $modified->count()); + /** @Then no elements should be removed */ + self::assertSame(2, $actual->count()); } - public function testChainedOperationsWhenObjectElementsThenPipelineProducesExpectedResult(): void + public function testSortWhenCustomComparatorGivenThenElementsAreOrderedByComparator(): void { /** @Given a lazy collection of Amount objects */ $collection = Collection::createLazyFrom(elements: [ - new Amount(value: 50.00, currency: Currency::USD), + new Amount(value: 300.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) + new Amount(value: 200.00, currency: Currency::USD) ]); - /** @And a variable to accumulate the total discounted value */ - $totalDiscounted = 0.0; - - /** @When chaining filter, map, removeAll and sort */ - $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) - ); - - /** @And accumulating the total discounted value via each */ - $actual->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); + /** @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) + ); - /** @And the first Amount should be 90 after the discount */ - self::assertSame(90.00, $actual->first()->value); + /** @Then the first element should have the lowest value */ + self::assertSame(100.00, $actual->first()->value); - /** @And the last Amount should be 225 after the discount */ - self::assertSame(225.00, $actual->last()->value); + /** @And the last element should have the highest value */ + self::assertSame(300.00, $actual->last()->value); } - public function testChainedOperationsWhenIntegerElementsThenPipelineProducesExpectedResult(): void + public function testReduceWhenAccumulatorGivenThenElementsAreAccumulatedToSingleValue(): 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); + /** @Given a lazy collection of integers */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4]); - /** @Then the first element should be 10000 (square of 100) */ - self::assertSame(10000, $actual->first()); + /** @When reducing to calculate the sum */ + $actual = $collection->reduce( + accumulator: static fn(int $carry, int $value): int => ($carry + $value), + initial: 0 + ); - /** @And the last element should be 4 (square of 2) */ - self::assertSame(4, $actual->last()); + /** @Then the sum should be 10 */ + self::assertSame(10, $actual); } - public function testReduceWhenAppliedOverChainedPipelineThenSumIsReturned(): void + public function testCreateLazyFromClosureWhenClosureYieldsNothingThenCollectionIsEmpty(): void { - /** @Given a lazy collection of integers from 1 to 100 */ - $collection = Collection::createLazyFrom(elements: range(1, 100)); + /** @Given a closure that yields nothing */ + $collection = Collection::createLazyFromClosure(factory: static function (): Generator { + yield from []; + }); - /** @And the collection is filtered to even numbers, squared, and sorted descending */ - $pipeline = $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); + /** @When checking the collection */ + $isEmpty = $collection->isEmpty(); - /** @When reducing to calculate the sum of all squared even numbers */ - $sum = $pipeline->reduce( - accumulator: static fn(int $carry, int $value): int => ($carry + $value), - initial: 0 - ); + /** @Then the collection should be empty */ + self::assertTrue($isEmpty); - /** @Then the sum should be 171700 */ - self::assertSame(171700, $sum); + /** @And the count should be zero */ + self::assertSame(0, $collection->count()); } - public function testCreateLazyFromClosureWhenGeneratorClosureThenHoldsAllElements(): void + public function testSortWhenAscendingValueWithoutComparatorThenDefaultComparisonIsUsed(): void { - /** @Given a closure that yields three elements */ - $factory = static function (): Generator { - yield 1; - yield 2; - yield 3; - }; - - /** @When creating a lazy collection from the closure */ - $collection = Collection::createLazyFromClosure(factory: $factory); + /** @Given a lazy collection with unordered integers */ + $collection = Collection::createLazyFrom(elements: [3, 1, 4, 1, 5]); - /** @Then the collection should contain all three elements */ - self::assertSame(3, $collection->count()); + /** @When sorting ascending by value without a custom comparator */ + $actual = $collection->sort(order: Order::ASCENDING_VALUE); - /** @And the array should match the expected elements */ - self::assertSame([1, 2, 3], $collection->toArray()); + /** @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 testCreateLazyFromClosureWhenConsumedMultipleTimesThenRemainsReiterable(): void @@ -1150,23 +1176,6 @@ public function testCreateLazyFromClosureWhenConsumedMultipleTimesThenRemainsRei self::assertSame(30, $collection->last()); } - public function testCreateLazyFromClosureWhenClosureYieldsNothingThenCollectionIsEmpty(): void - { - /** @Given a closure that yields nothing */ - $collection = Collection::createLazyFromClosure(factory: static function (): Generator { - yield from []; - }); - - /** @When checking the collection */ - $isEmpty = $collection->isEmpty(); - - /** @Then the collection should be empty */ - self::assertTrue($isEmpty); - - /** @And the count should be zero */ - self::assertSame(0, $collection->count()); - } - public function testChainedOperationsWhenClosureBackedThenPipelineProducesExpectedResult(): void { /** @Given a closure-backed collection with integers */ @@ -1184,90 +1193,81 @@ public function testChainedOperationsWhenClosureBackedThenPipelineProducesExpect self::assertSame([30, 40, 50], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } - public function testReduceWhenClosureBackedObjectsThenAmountsAreSummed(): void + public function testFindByWhenMultiplePredicatesThenFirstMatchAcrossPredicatesIsReturned(): void { - /** @Given a closure that yields Amount objects */ - $collection = Collection::createLazyFromClosure(factory: static function (): Generator { - yield new Amount(value: 100.00, currency: Currency::USD); - yield new Amount(value: 200.00, currency: Currency::USD); - yield new Amount(value: 300.00, currency: Currency::USD); - }); + /** @Given a lazy collection of integers */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5]); - /** @When reducing to sum all amounts */ - $total = $collection->reduce( - accumulator: static fn(float $carry, Amount $amount): float => ($carry + $amount->value), - initial: 0.0 - ); + /** @When finding by multiple predicates (OR semantics) */ + $matchTen = static fn(int $value): bool => $value === 10; + $matchThree = static fn(int $value): bool => $value === 3; - /** @Then the total should be 600 */ - self::assertSame(600.00, $total); + $actual = $collection->findBy($matchTen, $matchThree); + + /** @Then it should return the first element matching any predicate */ + self::assertSame(3, $actual); } - public function testGetByWhenClosureBackedCollectionThenReturnsElementAtIndex(): void + public function testChainedOperationsWhenObjectElementsThenPipelineProducesExpectedResult(): void { - /** @Given a closure-backed collection */ - $collection = Collection::createLazyFromClosure(factory: static function (): Generator { - yield 'alpha'; - yield 'beta'; - yield 'gamma'; - }); + /** @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) + ]); - /** @When retrieving element at index 1 */ - $actual = $collection->getBy(index: 1); + /** @And a variable to accumulate the total discounted value */ + $totalDiscounted = 0.0; - /** @Then it should return the second element */ - self::assertSame('beta', $actual); - } + /** @When chaining filter, map, removeAll and sort */ + $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) + ); - public function testContainsWhenClosureBackedCollectionThenDetectsMembership(): void - { - /** @Given a closure-backed collection */ - $collection = Collection::createLazyFromClosure(factory: static function (): Generator { - yield 'alpha'; - yield 'beta'; - yield 'gamma'; + /** @And accumulating the total discounted value via each */ + $actual->each(actions: function (Amount $amount) use (&$totalDiscounted): void { + $totalDiscounted += $amount->value; }); - /** @When checking if the collection contains an existing element */ - $containsBeta = $collection->contains(element: 'beta'); - - /** @Then it should return true */ - self::assertTrue($containsBeta); - - /** @And checking for a non-existing element should return false */ - self::assertFalse($collection->contains(element: 'delta')); - } + /** @Then the final collection should contain exactly three elements */ + self::assertCount(3, $actual); - public function testAddWhenClosureBackedCollectionThenElementsAreAppended(): void - { - /** @Given a closure-backed collection */ - $collection = Collection::createLazyFromClosure(factory: static function (): Generator { - yield 1; - yield 2; - }); + /** @And the total discounted value should be 450 */ + self::assertSame(450.00, $totalDiscounted); - /** @When adding elements */ - $actual = $collection->add(3, 4); + /** @And the first Amount should be 90 after the discount */ + self::assertSame(90.00, $actual->first()->value); - /** @Then all elements should be present */ - self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @And the last Amount should be 225 after the discount */ + self::assertSame(225.00, $actual->last()->value); } - public function testMergeWhenClosureBackedMergedWithEagerThenElementsAreCombined(): void + public function testChainedOperationsWhenIntegerElementsThenPipelineProducesExpectedResult(): void { - /** @Given a closure-backed collection */ - $closureCollection = Collection::createLazyFromClosure(factory: static function (): Generator { - yield 1; - yield 2; - }); + /** @Given a lazy collection of integers from 1 to 100 */ + $collection = Collection::createLazyFrom(elements: range(1, 100)); - /** @And an eager collection */ - $eagerCollection = Collection::createFrom(elements: [3, 4]); + /** @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); - /** @When merging them */ - $actual = $closureCollection->merge(other: $eagerCollection); + /** @Then the first element should be 10000 (square of 100) */ + self::assertSame(10000, $actual->first()); - /** @Then the result should contain all elements */ - self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + /** @And the last element should be 4 (square of 2) */ + self::assertSame(4, $actual->last()); } } diff --git a/tests/Models/Invoices.php b/tests/Models/Invoices.php index bfb03bb..c67ee49 100644 --- a/tests/Models/Invoices.php +++ b/tests/Models/Invoices.php @@ -13,18 +13,18 @@ #[ElementType(Invoice::class)] final class Invoices extends Collection { - public function totalAmount(): float + public function forCustomer(string $customer): Invoices { - return $this->reduce( - accumulator: static fn(float $carry, Invoice $invoice): float => ($carry + $invoice->amount), - initial: 0.0 + return $this->filter( + predicates: static fn(Invoice $invoice): bool => $invoice->customer === $customer ); } - public function forCustomer(string $customer): Invoices + public function totalAmount(): float { - return $this->filter( - predicates: static fn(Invoice $invoice): bool => $invoice->customer === $customer + return $this->reduce( + accumulator: static fn(float $carry, Invoice $invoice): float => ($carry + $invoice->amount), + initial: 0.0 ); } }