diff --git a/src/CollectionLogic.php b/src/CollectionLogic.php index 8f28094..15e62f0 100644 --- a/src/CollectionLogic.php +++ b/src/CollectionLogic.php @@ -44,6 +44,9 @@ */ trait CollectionLogic { + /** @use IterableTerminalsLogic */ + use IterableTerminalsLogic; + // --- Element Access --- /** {@inheritDoc} */ @@ -70,50 +73,6 @@ public function lastOrNull(): mixed return $this->store->last(); } - /** {@inheritDoc} */ - public function single() - { - $found = false; - $result = null; - - foreach ($this as $v) { - if ($found) { - throw new NoSuchElementException('Collection contains more than one element'); - } - - $result = $v; - $found = true; - } - - if (!$found) { - throw new NoSuchElementException('Collection is empty'); - } - - return $result; // @phpstan-ignore return.type - } - - /** {@inheritDoc} */ - public function singleOrNull(): mixed - { - try { - return $this->single(); - } catch (NoSuchElementException) { - return null; - } - } - - /** {@inheritDoc} */ - public function find(Closure $predicate): mixed - { - foreach ($this as $i => $v) { - if ($predicate($v, $i)) { - return $v; - } - } - - return null; - } - /** {@inheritDoc} */ public function findLast(Closure $predicate): mixed { @@ -128,18 +87,6 @@ public function findLast(Closure $predicate): mixed return $result; } - /** {@inheritDoc} */ - public function expect(Closure $predicate) - { - foreach ($this as $i => $v) { - if ($predicate($v, $i)) { - return $v; - } - } - - throw new NoSuchElementException('No element matching the predicate was found'); - } - /** {@inheritDoc} */ public function expectLast(Closure $predicate) { diff --git a/src/Exception/NamesItsSubject.php b/src/Exception/NamesItsSubject.php new file mode 100644 index 0000000..0186171 --- /dev/null +++ b/src/Exception/NamesItsSubject.php @@ -0,0 +1,36 @@ +|Sequence $subject + */ + private static function subjectName(Collection|Sequence $subject): string + { + // The parameter type makes this exhaustive, so the default arm *is* the Collection case + // rather than a catch-all. Widening that union means adding an arm here. + return match (true) { + $subject instanceof Sequence => 'Sequence', + default => 'Collection', + }; + } +} diff --git a/src/Exception/NoSuchElementException.php b/src/Exception/NoSuchElementException.php index 754cdd8..7ee8b1e 100644 --- a/src/Exception/NoSuchElementException.php +++ b/src/Exception/NoSuchElementException.php @@ -10,7 +10,26 @@ namespace Noctud\Collection\Exception; use LogicException; +use Noctud\Collection\Collection; +use Noctud\Collection\Sequence\Sequence; final class NoSuchElementException extends LogicException { + use NamesItsSubject; + + /** + * @param Collection|Sequence $subject + */ + public static function emptySubject(Collection|Sequence $subject): self + { + return new self(sprintf('%s is empty', self::subjectName($subject))); + } + + /** + * @param Collection|Sequence $subject + */ + public static function subjectHasMoreThanOneElement(Collection|Sequence $subject): self + { + return new self(sprintf('%s contains more than one element', self::subjectName($subject))); + } } diff --git a/src/IterableTerminalsLogic.php b/src/IterableTerminalsLogic.php new file mode 100644 index 0000000..52382a9 --- /dev/null +++ b/src/IterableTerminalsLogic.php @@ -0,0 +1,100 @@ + or Sequence depending on who uses the trait. + * + * @template E + * + * @internal + */ +trait IterableTerminalsLogic +{ + /** {@inheritDoc} */ + public function single() + { + $found = false; + $result = null; + + foreach ($this as $v) { + if ($found) { + throw NoSuchElementException::subjectHasMoreThanOneElement($this); + } + + $result = $v; + $found = true; + } + + if (!$found) { + throw NoSuchElementException::emptySubject($this); + } + + return $result; // @phpstan-ignore return.type + } + + /** + * {@inheritDoc} + * + * Not a try-catch around single(): iterating $this runs user closures on the lazy side, and a + * NoSuchElementException raised inside one is a real error, not an answer to this question. + */ + public function singleOrNull(): mixed + { + $found = false; + $result = null; + + foreach ($this as $v) { + if ($found) { + return null; + } + + $result = $v; + $found = true; + } + + return $result; + } + + /** {@inheritDoc} */ + public function find(Closure $predicate): mixed + { + foreach ($this as $i => $v) { + if ($predicate($v, $i)) { + return $v; + } + } + + return null; + } + + /** {@inheritDoc} */ + public function expect(Closure $predicate) + { + foreach ($this as $i => $v) { + if ($predicate($v, $i)) { + return $v; + } + } + + throw new NoSuchElementException('No element matching the predicate was found'); + } +} diff --git a/src/Sequence/Sequence.php b/src/Sequence/Sequence.php index 2f2d721..22368ac 100644 --- a/src/Sequence/Sequence.php +++ b/src/Sequence/Sequence.php @@ -11,8 +11,10 @@ use Closure; use IteratorAggregate; +use Noctud\Collection\Exception\IndexOutOfBoundsException; use Noctud\Collection\Exception\InvalidSequenceSourceException; use Noctud\Collection\Exception\NonReplayableSourceException; +use Noctud\Collection\Exception\NoSuchElementException; use Noctud\Collection\List\ImmutableList; use Noctud\Collection\Set\ImmutableSet; use NoDiscard; @@ -218,6 +220,115 @@ public function zipWithNext(): Sequence; #[NoDiscard] public function onEach(Closure $action): Sequence; + // --- Element Access --- + + /** + * Returns the first element, consuming one pass. Pulls exactly one element. + * + * @return E + * @throws NoSuchElementException If the sequence is empty + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function first(); + + /** + * Returns the first element, or null if the sequence is empty. Pulls exactly one element. + * + * @return E|null + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function firstOrNull(): mixed; + + /** + * Returns the last element, consuming one pass. + * Unlike its Collection counterpart this drains the whole sequence - the last element is + * only knowable at the end. + * + * @return E + * @throws NoSuchElementException If the sequence is empty + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function last(); + + /** + * Returns the last element, or null if the sequence is empty. Drains the sequence. + * + * @return E|null + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function lastOrNull(): mixed; + + /** + * Returns the single element, consuming one pass. Pulls at most two elements: a second one + * existing is already an error. + * + * @return E + * @throws NoSuchElementException If the sequence is empty or holds more than one element + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function single(); + + /** + * Returns the single element, or null if the sequence is empty or holds more than one. + * + * @return E|null + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function singleOrNull(): mixed; + + /** + * Returns the element at the given position, consuming one pass. + * Pulls up to that position and no further; there is no length to check the index against + * beforehand, so an index past the end is only known once the sequence runs out. + * + * @param non-negative-int $index + * @return E + * @throws IndexOutOfBoundsException If the sequence holds fewer elements than that or if the index is a negative int + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function elementAt(int $index); + + /** + * Returns the element at the given position, or null if the sequence is shorter than that. + * + * @param non-negative-int $index + * @return E|null + * @throws IndexOutOfBoundsException If the index is a negative int + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function elementAtOrNull(int $index): mixed; + + /** + * Returns the first element matching the predicate, or null if none does. + * Stops pulling at the first match. + * + * @param Closure(E, int):bool $predicate + * @return E|null + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function find(Closure $predicate): mixed; + + /** + * Returns the first element matching the predicate, throwing if none does. + * Stops pulling at the first match. + * + * @param Closure(E, int):bool $predicate + * @return E + * @throws NoSuchElementException If no element matches the predicate + * @throws NonReplayableSourceException If a non-replayable source has already been consumed + * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + */ + public function expect(Closure $predicate); + // --- Conversion --- /** diff --git a/src/Sequence/SequenceLogic.php b/src/Sequence/SequenceLogic.php index 5c216db..487e303 100644 --- a/src/Sequence/SequenceLogic.php +++ b/src/Sequence/SequenceLogic.php @@ -12,8 +12,11 @@ use Closure; use Generator; use IteratorAggregate; +use Noctud\Collection\Exception\IndexOutOfBoundsException; use Noctud\Collection\Exception\InvalidSequenceSourceException; use Noctud\Collection\Exception\NonReplayableSourceException; +use Noctud\Collection\Exception\NoSuchElementException; +use Noctud\Collection\IterableTerminalsLogic; use Noctud\Collection\List\ImmutableList; use Noctud\Collection\Operation\DistinctOperation; use Noctud\Collection\Operation\DropOperation; @@ -37,6 +40,9 @@ */ trait SequenceLogic { + /** @use IterableTerminalsLogic */ + use IterableTerminalsLogic; + /** @var iterable|Closure():iterable */ private iterable|Closure $source; @@ -213,6 +219,108 @@ public function onEach(Closure $action): Sequence })); } + // --- Element Access --- + + /** + * {@inheritDoc} + * + * The eager side answers this from its store; a sequence has to pull, and returning inside + * the foreach is what keeps it to a single element. + */ + public function first() + { + foreach ($this as $v) { + return $v; + } + + throw NoSuchElementException::emptySubject($this); + } + + /** + * {@inheritDoc} + * + * Kotlin's index accessor rather than a List's: a negative index is rejected up front, but + * there is no length to bounds-check against, so an index past the end is only known once + * the source runs out. + */ + public function elementAt(int $index) + { + // @phpstan-ignore smaller.alwaysFalse (defensive guard: the phpdoc type does not bind untyped callers) + if ($index < 0) { + throw new IndexOutOfBoundsException('Cannot use a negative index.'); + } + + foreach ($this as $i => $v) { + if ($i === $index) { + return $v; + } + } + + throw new IndexOutOfBoundsException('Index out of bounds: ' . $index); + } + + /** {@inheritDoc} */ + public function elementAtOrNull(int $index): mixed + { + // @phpstan-ignore smaller.alwaysFalse (defensive guard: the phpdoc type does not bind untyped callers) + if ($index < 0) { + throw new IndexOutOfBoundsException('Cannot use a negative index.'); + } + + foreach ($this as $i => $v) { + if ($i === $index) { + return $v; + } + } + + return null; + } + + /** {@inheritDoc} */ + public function firstOrNull(): mixed + { + foreach ($this as $v) { + return $v; + } + + return null; + } + + /** + * {@inheritDoc} + * + * No array_key_last to lean on here: the last element is only knowable once the source is + * exhausted, so this drains it. + */ + public function last() + { + $found = false; + $result = null; + + foreach ($this as $v) { + $result = $v; + $found = true; + } + + if (!$found) { + throw NoSuchElementException::emptySubject($this); + } + + return $result; // @phpstan-ignore return.type + } + + /** {@inheritDoc} */ + public function lastOrNull(): mixed + { + $result = null; + + foreach ($this as $v) { + $result = $v; + } + + return $result; + } + // --- Conversion --- /** {@inheritDoc} */ diff --git a/tests/Collection/CollectionAggregate.php b/tests/Collection/CollectionAggregate.php index ee3c67d..2f54c3c 100644 --- a/tests/Collection/CollectionAggregate.php +++ b/tests/Collection/CollectionAggregate.php @@ -68,6 +68,7 @@ public function avg_throws_for_empty_collection(): void $collection = $this->collectionOf([]); $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessageIsOrContains('Cannot compute average of empty collection'); $collection->avg(); } @@ -126,6 +127,7 @@ public function max_throws_for_empty_collection(): void $collection = $this->collectionOf([]); $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessageIsOrContains('Collection is empty'); $collection->max(); } @@ -192,6 +194,7 @@ public function min_throws_for_empty_collection(): void $collection = $this->collectionOf([]); $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessageIsOrContains('Collection is empty'); $collection->min(); } @@ -361,6 +364,7 @@ public function minOf_throws_for_empty(): void $collection = $this->collectionOf([]); $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessageIsOrContains('Collection is empty'); $collection->minOf(fn ($element) => $element); } @@ -406,6 +410,7 @@ public function maxOf_throws_for_empty(): void $collection = $this->collectionOf([]); $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessageIsOrContains('Collection is empty'); $collection->maxOf(fn ($element) => $element); } diff --git a/tests/Collection/CollectionSingle.php b/tests/Collection/CollectionSingle.php index 283759f..3558182 100644 --- a/tests/Collection/CollectionSingle.php +++ b/tests/Collection/CollectionSingle.php @@ -27,6 +27,7 @@ public function single_throws_on_empty(): void $collection = $this->collectionOf([]); $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessageIsOrContains('Collection is empty'); $collection->single(); } @@ -36,6 +37,7 @@ public function single_throws_on_multiple(): void $collection = $this->collectionOf([1, 2]); $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessageIsOrContains('Collection contains more than one element'); $collection->single(); } diff --git a/tests/Sequence/SequenceTerminalTest.php b/tests/Sequence/SequenceTerminalTest.php new file mode 100644 index 0000000..3847a2f --- /dev/null +++ b/tests/Sequence/SequenceTerminalTest.php @@ -0,0 +1,350 @@ +assertSame(1, sequenceOf([1, 2, 3])->first()); + } + + #[Test] + public function first_throws_on_an_empty_sequence(): void + { + $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessageIsOrContains('Sequence is empty'); + + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $_ = sequenceOf([])->first(); + } + + #[Test] + public function firstOrNull_returns_null_on_an_empty_sequence(): void + { + $this->assertSame(1, sequenceOf([1, 2])->firstOrNull()); + + // Emptied by a filter rather than empty at the source: the realistic way a pipeline + // ends up with nothing, and it keeps a real element type instead of never. + $this->assertNull(sequenceOf([1, 2])->filter(static fn (int $v): bool => $v > 9)->firstOrNull()); + } + + #[Test] + public function last_returns_the_last_element(): void + { + $this->assertSame(3, sequenceOf([1, 2, 3])->last()); + } + + #[Test] + public function last_throws_on_an_empty_sequence(): void + { + $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessageIsOrContains('Sequence is empty'); + + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $_ = sequenceOf([])->last(); + } + + #[Test] + public function lastOrNull_returns_null_on_an_empty_sequence(): void + { + $this->assertSame(3, sequenceOf([1, 2, 3])->lastOrNull()); + $this->assertNull(sequenceOf([1, 2])->filter(static fn (int $v): bool => $v > 9)->lastOrNull()); + } + + #[Test] + public function lastOrNull_returns_a_trailing_null_element(): void + { + // A sequence ending on null is indistinguishable from an empty one through this + // terminal - the same ambiguity Collection::lastOrNull() has, kept deliberately. + $this->assertNull(sequenceOf([1, null])->lastOrNull()); + } + + #[Test] + public function single_returns_the_only_element(): void + { + $this->assertSame(1, sequenceOf([1])->single()); + } + + #[Test] + public function single_throws_on_an_empty_sequence(): void + { + $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessageIsOrContains('Sequence is empty'); + + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $_ = sequenceOf([])->single(); + } + + #[Test] + public function single_throws_on_more_than_one_element(): void + { + $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessageIsOrContains('Sequence contains more than one element'); + + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $_ = sequenceOf([1, 2])->single(); + } + + #[Test] + public function singleOrNull_returns_null_when_empty_or_ambiguous(): void + { + $this->assertSame(1, sequenceOf([1])->singleOrNull()); + $this->assertNull(sequenceOf([1, 2])->filter(static fn (int $v): bool => $v > 9)->singleOrNull()); + $this->assertNull(sequenceOf([1, 2])->singleOrNull()); + } + + #[Test] + public function singleOrNull_propagates_a_NoSuchElementException_thrown_by_a_stage(): void + { + // A mapper failing over some other subject is a real error, not "no single element" here. + $sequence = sequenceOf([1])->map(static function (): int { + throw new NoSuchElementException('Raised inside the pipeline'); + }); + + $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessageIsOrContains('Raised inside the pipeline'); + + $_ = $sequence->singleOrNull(); // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + } + + #[Test] + public function elementAt_returns_the_element_at_that_position(): void + { + $this->assertSame('b', sequenceOf(['a', 'b', 'c'])->elementAt(1)); + $this->assertSame('a', sequenceOf(['a', 'b', 'c'])->elementAt(0)); + } + + #[Test] + public function elementAt_throws_past_the_end(): void + { + $this->expectException(IndexOutOfBoundsException::class); + $this->expectExceptionMessageIsOrContains('Index out of bounds: 3'); + + $_ = sequenceOf(['a', 'b', 'c'])->elementAt(3); // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + } + + #[Test] + public function elementAt_throws_on_a_negative_index(): void + { + $this->expectException(IndexOutOfBoundsException::class); + $this->expectExceptionMessageIsOrContains('Cannot use a negative index'); + + // @phpstan-ignore argument.type + $_ = sequenceOf(['a', 'b', 'c'])->elementAt(-1); // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + } + + #[Test] + public function elementAtOrNull_returns_null_past_the_end(): void + { + $this->assertSame('b', sequenceOf(['a', 'b', 'c'])->elementAtOrNull(1)); + $this->assertNull(sequenceOf(['a', 'b', 'c'])->elementAtOrNull(3)); + } + + #[Test] + public function elementAtOrNull_throws_on_a_negative_index(): void + { + $this->expectException(IndexOutOfBoundsException::class); + $this->expectExceptionMessageIsOrContains('Cannot use a negative index'); + + // @phpstan-ignore argument.type + $_ = sequenceOf(['a', 'b', 'c'])->elementAtOrNull(-1); // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + } + + #[Test] + public function elementAt_counts_the_positions_of_its_own_stage(): void + { + // Indexes are positional per stage, so a filter renumbers what elementAt() counts. + $sequence = sequenceOf([1, 2, 3, 4])->filter(static fn (int $v): bool => $v % 2 === 0); + + $this->assertSame(4, $sequence->elementAt(1)); + } + + #[Test] + public function elementAt_stops_pulling_at_that_position(): void + { + $pulled = []; + $sequence = sequenceOf(static function () use (&$pulled): Generator { + foreach ([1, 2, 3, 4] as $value) { + $pulled[] = $value; + + yield $value; + } + }); + + $this->assertSame(2, $sequence->elementAt(1)); + $this->assertSame([1, 2], $pulled); + } + + #[Test] + public function find_returns_the_first_match_or_null(): void + { + $this->assertSame(2, sequenceOf([1, 2, 3, 4])->find(static fn (int $v): bool => $v % 2 === 0)); + $this->assertNull(sequenceOf([1, 3])->find(static fn (int $v): bool => $v % 2 === 0)); + } + + #[Test] + public function find_receives_the_positional_index(): void + { + $seen = []; + $found = sequenceOf(['a', 'b', 'c']) + ->filter(static fn (string $v): bool => $v !== 'a') + ->find(static function (string $v, int $i) use (&$seen): bool { + $seen[] = "$i:$v"; + + return $v === 'c'; + }); + + // Indexes are the positions of this stage, not of the source: the filter reindexed. + $this->assertSame('c', $found); + $this->assertSame(['0:b', '1:c'], $seen); + } + + #[Test] + public function expect_returns_the_first_match(): void + { + $this->assertSame(2, sequenceOf([1, 2, 3])->expect(static fn (int $v): bool => $v % 2 === 0)); + } + + #[Test] + public function expect_throws_when_nothing_matches(): void + { + $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessageIsOrContains('No element matching the predicate was found'); + + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $_ = sequenceOf([1, 3])->expect(static fn (int $v): bool => $v % 2 === 0); + } + + #[Test] + public function element_access_matches_its_collection_counterpart(): void + { + $data = [3, 1, 4, 1, 5]; + $even = static fn (int $v): bool => $v % 2 === 0; + + $this->assertSame(listOf($data)->first(), sequenceOf($data)->first()); + $this->assertSame(listOf($data)->firstOrNull(), sequenceOf($data)->firstOrNull()); + $this->assertSame(listOf($data)->last(), sequenceOf($data)->last()); + $this->assertSame(listOf($data)->lastOrNull(), sequenceOf($data)->lastOrNull()); + $this->assertSame(listOf($data)->find($even), sequenceOf($data)->find($even)); + $this->assertSame(listOf($data)->expect($even), sequenceOf($data)->expect($even)); + $this->assertSame(listOf([7])->single(), sequenceOf([7])->single()); + $this->assertSame(listOf($data)->singleOrNull(), sequenceOf($data)->singleOrNull()); + } + + #[Test] + public function first_pulls_exactly_one_element(): void + { + $pulled = []; + $sequence = sequenceOf(static function () use (&$pulled): Generator { + foreach ([1, 2, 3] as $value) { + $pulled[] = $value; + + yield $value; + } + }); + + $this->assertSame(1, $sequence->first()); + $this->assertSame([1], $pulled); + } + + #[Test] + public function single_pulls_at_most_two_elements(): void + { + $pulled = []; + $sequence = sequenceOf(static function () use (&$pulled): Generator { + foreach ([1, 2, 3, 4] as $value) { + $pulled[] = $value; + + yield $value; + } + }); + + try { + // A second element existing is already the answer: nothing beyond it is pulled. + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $_ = $sequence->single(); + } catch (NoSuchElementException) { + // expected + } + + $this->assertSame([1, 2], $pulled); + } + + #[Test] + public function find_stops_pulling_at_the_first_match(): void + { + $pulled = []; + $sequence = sequenceOf(static function () use (&$pulled): Generator { + foreach ([1, 2, 3, 4] as $value) { + $pulled[] = $value; + + yield $value; + } + }); + + $this->assertSame(2, $sequence->find(static fn (int $v): bool => $v % 2 === 0)); + $this->assertSame([1, 2], $pulled); + } + + #[Test] + public function last_drains_the_source(): void + { + $pulled = []; + $sequence = sequenceOf(static function () use (&$pulled): Generator { + foreach ([1, 2, 3] as $value) { + $pulled[] = $value; + + yield $value; + } + }); + + // No array_key_last to lean on: the last element is only knowable at the end. + $this->assertSame(3, $sequence->last()); + $this->assertSame([1, 2, 3], $pulled); + } + + #[Test] + public function a_terminal_consumes_a_pass_of_a_one_shot_source(): void + { + $sequence = sequenceOf((static function (): Generator { + yield 1; + yield 2; + })()); + + $this->assertSame(1, $sequence->first()); + + // Even a partial pass counts as consumed - there is no resuming from the middle. + $this->expectException(NonReplayableSourceException::class); + + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $_ = $sequence->first(); + } + + #[Test] + public function element_access_replays_over_a_replayable_source(): void + { + $sequence = sequenceOf([1, 2, 3]); + + $this->assertSame(1, $sequence->first()); + $this->assertSame(3, $sequence->last()); + $this->assertSame(1, $sequence->first()); + } +} diff --git a/tests/Type/SequenceTerminalTypeTest.php b/tests/Type/SequenceTerminalTypeTest.php new file mode 100644 index 0000000..ab23044 --- /dev/null +++ b/tests/Type/SequenceTerminalTypeTest.php @@ -0,0 +1,44 @@ + $s */ +$s = sequenceOf(['a', 'b', 'c']); + +// The throwing element accessors return the element type itself. +assertType('string', $s->first()); +assertType('string', $s->last()); +assertType('string', $s->single()); +assertType('string', $s->expect(static fn (string $v): bool => $v !== 'a')); + +// Their OrNull counterparts widen it with null, find() included. +assertType('string|null', $s->firstOrNull()); +assertType('string|null', $s->lastOrNull()); +assertType('string|null', $s->singleOrNull()); +assertType('string|null', $s->find(static fn (string $v): bool => $v !== 'a')); + +// Index access carries the element type, and widens with null in the OrNull variant. +assertType('string', $s->elementAt(1)); +assertType('string|null', $s->elementAtOrNull(1)); + +// The element type follows the pipeline rather than the source. +assertType('float', sequenceOf([1, 2, 3])->map(static fn (int $v): float => $v * 2.5)->first()); +assertType('int|null', sequenceOf([1, 2, 3])->filter(static fn (int $v): bool => $v > 1)->firstOrNull()); + +/** @var Sequence $nullable */ +$nullable = sequenceOf(['a', null]); + +// filterNotNull() narrows what the accessors can hand back. +assertType('string|null', $nullable->firstOrNull()); +assertType('string', $nullable->filterNotNull()->first());