diff --git a/src/Collection.php b/src/Collection.php index 216bb3f..87a3ca4 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -190,6 +190,7 @@ public function count(): int; * Returns the number of elements matching the predicate. * * @param Closure(E, int):bool $predicate + * @return int<0, max> */ public function countWhere(Closure $predicate): int; diff --git a/src/List/ListInterface.php b/src/List/ListInterface.php index e02a18e..6053e65 100644 --- a/src/List/ListInterface.php +++ b/src/List/ListInterface.php @@ -42,6 +42,16 @@ public function get(int $index); */ public function getOrNull(int $index): mixed; + /** + * Returns the element at the specified index, or throws if out of bounds. + * Alias of get() for array access syntax `$list[0]`. + * + * @param int $offset + * @return E + * @throws IndexOutOfBoundsException + */ + public function offsetGet(mixed $offset): mixed; + /** * Returns the element at the specified index, or the default value if out of bounds. * diff --git a/src/Map/Map.php b/src/Map/Map.php index 82c96a7..26691c1 100644 --- a/src/Map/Map.php +++ b/src/Map/Map.php @@ -79,6 +79,16 @@ interface Map extends IteratorAggregate, Countable, ArrayAccess, JsonSerializabl */ public function get(string|int|bool|float|object $key); + /** + * Retrieves the value associated with the given key, or throws if absent. + * Alias of get() for array access syntax `$map['key']`. + * + * @param K $offset + * @return V + * @throws NoSuchElementException + */ + public function offsetGet(mixed $offset): mixed; + /** * Retrieves the value associated with the given key. * Returns null if the key does not exist in the map. @@ -163,6 +173,7 @@ public function count(): int; * Returns the number of entries matching the predicate. * * @param Closure(V, K):bool $predicate + * @return int<0, max> */ public function countWhere(Closure $predicate): int; diff --git a/tests/Type/Collection/AccessTypeTest.php b/tests/Type/Collection/AccessTypeTest.php new file mode 100644 index 0000000..9fd4771 --- /dev/null +++ b/tests/Type/Collection/AccessTypeTest.php @@ -0,0 +1,30 @@ + $c */ +$c = listOf([1, 2, 3]); + +assertType('int', $c->first()); +assertType('int|null', $c->firstOrNull()); +assertType('int', $c->last()); +assertType('int|null', $c->lastOrNull()); +assertType('int', $c->single()); +assertType('int|null', $c->singleOrNull()); +assertType('int|null', $c->find(fn (int $x): bool => $x > 0)); +assertType('int|null', $c->findLast(fn (int $x): bool => $x > 0)); +assertType('int', $c->expect(fn (int $x): bool => $x > 0)); +assertType('int', $c->expectLast(fn (int $x): bool => $x > 0)); +assertType('int', $c->random()); +assertType('int|null', $c->randomOrNull()); diff --git a/tests/Type/Collection/AggregationTypeTest.php b/tests/Type/Collection/AggregationTypeTest.php new file mode 100644 index 0000000..20b9c6b --- /dev/null +++ b/tests/Type/Collection/AggregationTypeTest.php @@ -0,0 +1,42 @@ + $c */ +$c = listOf([1, 2, 3]); + +// fold: return type follows the initial accumulator type R. +assertType('int', $c->fold(0, fn (int $acc, int $x): int => $acc + $x)); +assertType('string', $c->fold('', fn (string $acc, int $x): string => $acc . $x)); + +assertType('int', $c->reduce(fn (int $acc, int $x): int => $acc + $x)); +assertType('int|null', $c->reduceOrNull(fn (int $acc, int $x): int => $acc + $x)); + +// min/max return the element type E (declared `: mixed`), not a fixed scalar. +assertType('int', $c->min()); +assertType('int|null', $c->minOrNull()); +assertType('int', $c->max()); +assertType('int|null', $c->maxOrNull()); + +// sum: a collection of ints sums to an int. +assertType('int', listOf([1, 2, 3])->sum()); + +// As soon as a float is involved, the sum widens back to int|float. +assertType('float|int', listOf([1.0, 2.0])->sum()); +assertType('float|int', listOf([1, 2.0])->sum()); + +// With a selector declared to return int, the sum narrows to int. +assertType('int', listOf([1, 2, 3])->sum(fn (int $x): int => $x * 2)); +// Any other selector return type (float, or an un-inferrable/mixed one) stays int|float. +assertType('float|int', listOf([1, 2, 3])->sum(fn (int $x): float => $x / 2)); diff --git a/tests/Type/Collection/ConversionTypeTest.php b/tests/Type/Collection/ConversionTypeTest.php new file mode 100644 index 0000000..fb4f0f3 --- /dev/null +++ b/tests/Type/Collection/ConversionTypeTest.php @@ -0,0 +1,34 @@ + $c */ +$c = listOf(['a', 'b', 'c']); + +// toMap: value defaults to the element type unless a value transform is given. +assertType( + 'Noctud\Collection\Map\ImmutableMap', + $c->toMap(fn (string $x): string => $x), +); +assertType( + 'Noctud\Collection\Map\ImmutableMap', + $c->toMap(fn (string $x): string => $x, fn (string $x): bool => $x !== ''), +); + +assertType('Noctud\Collection\List\ImmutableList', $c->toList()); +assertType('Noctud\Collection\Set\ImmutableSet', $c->toSet()); +assertType('list', $c->toArray()); +assertType('Noctud\Collection\MutableCollection', $c->toMutable()); +assertType('Noctud\Collection\ImmutableCollection', $c->toImmutable()); +assertType('Noctud\Collection\Collection', $c->forEach(fn (string $x): null => null)); diff --git a/tests/Type/Collection/OrderingTypeTest.php b/tests/Type/Collection/OrderingTypeTest.php new file mode 100644 index 0000000..f704593 --- /dev/null +++ b/tests/Type/Collection/OrderingTypeTest.php @@ -0,0 +1,25 @@ + $c */ +$c = listOf([1, 2, 3]); + +assertType('Noctud\Collection\Collection', $c->sorted()); +assertType('Noctud\Collection\Collection', $c->sortedDesc()); +assertType('Noctud\Collection\Collection', $c->sortedBy(fn (int $x): int => $x)); +assertType('Noctud\Collection\Collection', $c->sortedByDesc(fn (int $x): int => $x)); +assertType('Noctud\Collection\Collection', $c->sortedWith(fn (int $a, int $b): int => $a <=> $b)); +assertType('Noctud\Collection\Collection', $c->reversed()); +assertType('Noctud\Collection\Collection', $c->shuffled()); diff --git a/tests/Type/Collection/QueryTypeTest.php b/tests/Type/Collection/QueryTypeTest.php new file mode 100644 index 0000000..01ab485 --- /dev/null +++ b/tests/Type/Collection/QueryTypeTest.php @@ -0,0 +1,27 @@ + $c */ +$c = listOf(['a', 'b', 'c']); + +// countWhere() pins the declared @return int<0, max>. +// count() itself is native-typed (`: int`, no PHPDoc) and needs no type test. +assertType('int<0, max>', $c->countWhere(fn (string $x): bool => $x !== '')); + +// countBy: the key selector's return becomes the map key, values are the counts. +assertType( + 'Noctud\Collection\Map\ImmutableMap', + $c->countBy(fn (string $x): string => $x), +); diff --git a/tests/Type/Collection/TransformTypeTest.php b/tests/Type/Collection/TransformTypeTest.php new file mode 100644 index 0000000..1a581a8 --- /dev/null +++ b/tests/Type/Collection/TransformTypeTest.php @@ -0,0 +1,68 @@ + $c */ +$c = listOf(['a', 'b', 'c']); + +// Filtering preserves the element type. +assertType('Noctud\Collection\Collection', $c->filter(fn (string $x): bool => $x !== '')); +assertType('Noctud\Collection\Collection', $c->filterNotNull()); +assertType('Noctud\Collection\Collection', $c->filterInstanceOf(stdClass::class)); + +/** @var Collection $nullable */ +$nullable = listOf(['a', null]); +assertType('Noctud\Collection\Collection', $nullable->filterNotNull()); + +// Mapping changes the element type. +assertType('Noctud\Collection\Collection', $c->map(fn (string $x): int => (int) $x)); +assertType('Noctud\Collection\Collection', $c->mapNotNull(fn (string $x): ?int => $x !== '' ? 1 : null)); +assertType('Noctud\Collection\Collection', $c->flatMap(fn (string $x): array => [(int) $x])); +assertType('Noctud\Collection\Collection', $c->flatten()); + +// Slicing preserves the element type. +assertType('Noctud\Collection\Collection', $c->takeFirst(2)); +assertType('Noctud\Collection\Collection', $c->dropFirst(2)); +assertType('Noctud\Collection\Collection', $c->takeLast(2)); +assertType('Noctud\Collection\Collection', $c->dropLast(2)); +assertType('Noctud\Collection\Collection', $c->takeWhile(fn (string $x): bool => $x !== '')); +assertType('Noctud\Collection\Collection', $c->dropWhile(fn (string $x): bool => $x !== '')); +assertType('Noctud\Collection\Collection', $c->takeLastWhile(fn (string $x): bool => $x !== '')); +assertType('Noctud\Collection\Collection', $c->dropLastWhile(fn (string $x): bool => $x !== '')); +assertType('Noctud\Collection\Collection', $c->distinct()); +assertType('Noctud\Collection\Collection', $c->distinctBy(fn (string $x): int => (int) $x)); + +// Grouping into lists of lists. +assertType('Noctud\Collection\List\ListInterface>', $c->chunked(2)); +assertType('Noctud\Collection\List\ListInterface>', $c->windowed(2)); +assertType('Noctud\Collection\List\ListInterface', $c->zip([1, 2])); +assertType('Noctud\Collection\List\ListInterface', $c->zipWithNext()); +assertType('array{Noctud\Collection\List\ImmutableList, Noctud\Collection\List\ImmutableList}', $c->unzip()); +assertType('array{Noctud\Collection\Collection, Noctud\Collection\Collection}', $c->partition(fn (string $x): bool => $x !== '')); + +// groupBy is conditional on the value transform. +assertType( + 'Noctud\Collection\Map\ImmutableMap>', + $c->groupBy(fn (string $x): string => $x), +); +assertType( + 'Noctud\Collection\Map\ImmutableMap>', + $c->groupBy(fn (string $x): string => $x, fn (string $x): int => (int) $x), +); + +// Set operations. +assertType('Noctud\Collection\Set\Set', $c->intersect(['a', 'b'])); +assertType('Noctud\Collection\Set\Set', $c->union(['a', 'b'])); +assertType('Noctud\Collection\Set\Set', $c->subtract(['a', 'b'])); diff --git a/tests/Type/CollectionSumTypeTest.php b/tests/Type/CollectionSumTypeTest.php deleted file mode 100644 index 645a5b7..0000000 --- a/tests/Type/CollectionSumTypeTest.php +++ /dev/null @@ -1,25 +0,0 @@ -sum()); - -// As soon as a float is involved, the sum widens back to int|float. -assertType('float|int', listOf([1.0, 2.0])->sum()); -assertType('float|int', listOf([1, 2.0])->sum()); - -// With a selector declared to return int, the sum narrows to int. -assertType('int', listOf([1, 2, 3])->sum(fn (int $x): int => $x * 2)); -// Any other selector return type (float, or an un-inferrable/mixed one) stays int|float. -assertType('float|int', listOf([1, 2, 3])->sum(fn (int $x): float => $x / 2)); diff --git a/tests/Type/FilterNotNullTypeTest.php b/tests/Type/FilterNotNullTypeTest.php deleted file mode 100644 index 9b3b8f1..0000000 --- a/tests/Type/FilterNotNullTypeTest.php +++ /dev/null @@ -1,38 +0,0 @@ -', listOf([1, null])->filterNotNull()); -assertType('Noctud\Collection\List\ImmutableList', mutableListOf([1, null])->filterNotNull()); -assertType('Noctud\Collection\Set\ImmutableSet', setOf([1, null])->filterNotNull()); -assertType('Noctud\Collection\Set\ImmutableSet', mutableSetOf([1, null])->filterNotNull()); - -// Also through the base Collection interface. -/** @var Collection $c */ -$c = listOf(['a', null]); -assertType('Noctud\Collection\Collection', $c->filterNotNull()); - -// A collection without null keeps its element type unchanged. -assertType('Noctud\Collection\List\ImmutableList', listOf([1, 2])->filterNotNull()); - -// filterValuesNotNull() removes null from the value type, keys are preserved. -assertType('Noctud\Collection\Map\ImmutableMap', stringMapOf(['a' => 1, 'b' => null])->filterValuesNotNull()); -assertType('Noctud\Collection\Map\ImmutableMap', mutableStringMapOf(['a' => 1, 'b' => null])->filterValuesNotNull()); diff --git a/tests/Type/FunctionsTypeTest.php b/tests/Type/FunctionsTypeTest.php new file mode 100644 index 0000000..aa8175d --- /dev/null +++ b/tests/Type/FunctionsTypeTest.php @@ -0,0 +1,46 @@ +', listOf(['a', 'b'])); +assertType('Noctud\Collection\List\MutableList', mutableListOf([1, 2])); + +// Sets +assertType('Noctud\Collection\Set\ImmutableSet', setOf(['a', 'b'])); +assertType('Noctud\Collection\Set\MutableSet', mutableSetOf([1, 2])); + +// Hash maps (assoc + pairs) +assertType('Noctud\Collection\Map\ImmutableMap', mapOf(['a' => 1, 'b' => 2])); +assertType('Noctud\Collection\Map\ImmutableMap', mapOfPairs([['a', 1], ['b', 2]])); +assertType('Noctud\Collection\Map\MutableMap', mutableMapOf(['a' => 1])); +assertType('Noctud\Collection\Map\MutableMap', mutableMapOfPairs([['a', 1]])); + +// String maps +assertType('Noctud\Collection\Map\ImmutableMap', stringMapOf(['a' => 1])); +assertType('Noctud\Collection\Map\MutableMap', mutableStringMapOf(['a' => 1])); + +// Int maps +assertType('Noctud\Collection\Map\ImmutableMap', intMapOf([1 => 'a'])); +assertType('Noctud\Collection\Map\MutableMap', mutableIntMapOf([1 => 'a'])); diff --git a/tests/Type/List/AccessTypeTest.php b/tests/Type/List/AccessTypeTest.php new file mode 100644 index 0000000..afe7d2f --- /dev/null +++ b/tests/Type/List/AccessTypeTest.php @@ -0,0 +1,31 @@ +get(0)); +assertType('int|null', $list->getOrNull(0)); +assertType("'x'|int", $list->getOrDefault(0, 'x')); +assertType("'x'|int", $list->getOrCompute(0, fn (): string => 'x')); +assertType('Noctud\Collection\List\ImmutableList', $list->slice(0, 2)); + +// Array access via [] returns the element (offsetGet throws on a missing index). +assertType('int', $list[0]); + +// toIndexedMap: value defaults to the element type unless a transform is given. +assertType('Noctud\Collection\Map\ImmutableMap', $list->toIndexedMap()); +assertType( + 'Noctud\Collection\Map\ImmutableMap', + $list->toIndexedMap(fn (int $x): bool => $x > 0), +); diff --git a/tests/Type/List/MutationTypeTest.php b/tests/Type/List/MutationTypeTest.php new file mode 100644 index 0000000..403ade2 --- /dev/null +++ b/tests/Type/List/MutationTypeTest.php @@ -0,0 +1,48 @@ +', $imm->add('x')); +assertType('Noctud\Collection\List\ImmutableList', $imm->addFirst('x')); +assertType('Noctud\Collection\List\ImmutableList', $imm->addAll(['x'])); +assertType('Noctud\Collection\List\ImmutableList', $imm->set(0, 'x')); + +// Removals keep the element type. +assertType('Noctud\Collection\List\ImmutableList', $imm->removeEvery(1)); +assertType('Noctud\Collection\List\ImmutableList', $imm->removeAt(0)); +assertType('Noctud\Collection\List\ImmutableList', $imm->removeIf(fn (int $x): bool => $x > 0)); +assertType('Noctud\Collection\List\ImmutableList', $imm->removeAll([1])); +assertType('Noctud\Collection\List\ImmutableList', $imm->removeElement(1)); +assertType('Noctud\Collection\List\ImmutableList', $imm->removeFirst()); +assertType('Noctud\Collection\List\ImmutableList', $imm->removeLast()); +assertType('Noctud\Collection\List\ImmutableList', $imm->retainAll([1])); + +// Mutable mutators are STRICT (@param E) and return the same MutableList. +$mut = mutableListOf([1, 2, 3]); +assertType('Noctud\Collection\List\MutableList', $mut->add(4)); +assertType('Noctud\Collection\List\MutableList', $mut->addFirst(0)); +assertType('Noctud\Collection\List\MutableList', $mut->addAll([4, 5])); +assertType('Noctud\Collection\List\MutableList', $mut->set(0, 9)); +assertType('Noctud\Collection\List\MutableList', $mut->removeEvery(1)); +assertType('Noctud\Collection\List\MutableList', $mut->removeAt(0)); +assertType('Noctud\Collection\List\MutableList', $mut->removeIf(fn (int $x): bool => $x > 0)); +assertType('Noctud\Collection\List\MutableList', $mut->clear()); +assertType('Noctud\Collection\List\MutableList', $mut->sort()); +assertType('Noctud\Collection\List\MutableList', $mut->sortBy(fn (int $x): int => $x)); +assertType('Noctud\Collection\List\MutableList', $mut->sortWith(fn (int $a, int $b): int => $a <=> $b)); +assertType('Noctud\Collection\List\MutableList', $mut->reverse()); +assertType('Noctud\Collection\List\MutableList', $mut->shuffle()); diff --git a/tests/Type/List/NarrowingTypeTest.php b/tests/Type/List/NarrowingTypeTest.php new file mode 100644 index 0000000..1f5babe --- /dev/null +++ b/tests/Type/List/NarrowingTypeTest.php @@ -0,0 +1,60 @@ +', $imm->filter(fn (int $x): bool => $x > 0)); +assertType('Noctud\Collection\List\ImmutableList', $imm->filterNotNull()); +assertType('Noctud\Collection\List\ImmutableList', listOf([1, null])->filterNotNull()); +assertType('Noctud\Collection\List\ImmutableList', $imm->takeFirst(2)); +assertType('Noctud\Collection\List\ImmutableList', $imm->dropLast(1)); +assertType('Noctud\Collection\List\ImmutableList', $imm->takeWhile(fn (int $x): bool => $x > 0)); +assertType('Noctud\Collection\List\ImmutableList', $imm->distinct()); +assertType('Noctud\Collection\List\ImmutableList', $imm->distinctBy(fn (int $x): int => $x)); +assertType('Noctud\Collection\List\ImmutableList', $imm->sorted()); +assertType('Noctud\Collection\List\ImmutableList', $imm->sortedBy(fn (int $x): int => $x)); +assertType('Noctud\Collection\List\ImmutableList', $imm->reversed()); +assertType('Noctud\Collection\List\ImmutableList', $imm->shuffled()); +assertType('Noctud\Collection\List\ImmutableList', $imm->slice(0, 2)); +assertType( + 'array{Noctud\Collection\List\ImmutableList, Noctud\Collection\List\ImmutableList}', + $imm->partition(fn (int $x): bool => $x > 0), +); + +// Type-changing transforms move to the new element type but stay ImmutableList. +assertType('Noctud\Collection\List\ImmutableList', $imm->map(fn (int $x): bool => $x > 0)); +assertType('Noctud\Collection\List\ImmutableList', $imm->filterInstanceOf(stdClass::class)); + +// chunked/windowed always produce a list of lists. They return the base ListInterface even +// on an ImmutableList: ListInterface's element type is invariant, so narrowing the nested +// type would break variance in the shared trait — and ListInterface<...> is already correct. +assertType('Noctud\Collection\List\ListInterface>', $imm->chunked(2)); + +// A mutable list's transforms produce a fresh immutable list. +$mut = mutableListOf([1, 2, 3]); +assertType('Noctud\Collection\List\ImmutableList', $mut->filter(fn (int $x): bool => $x > 0)); +assertType('Noctud\Collection\List\ImmutableList', $mut->map(fn (int $x): bool => $x > 0)); +assertType('Noctud\Collection\List\ImmutableList', $mut->sorted()); +assertType('Noctud\Collection\List\ImmutableList', mutableListOf([1, null])->filterNotNull()); + +// The base ListInterface contract keeps everything at the ListInterface level. +/** @var ListInterface $list */ +$list = listOf([1, 2, 3]); +assertType('Noctud\Collection\List\ListInterface', $list->filter(fn (int $x): bool => $x > 0)); +assertType('Noctud\Collection\List\ListInterface', $list->map(fn (int $x): bool => $x > 0)); +assertType('Noctud\Collection\List\ListInterface', $list->sorted()); diff --git a/tests/Type/Map/AccessTypeTest.php b/tests/Type/Map/AccessTypeTest.php new file mode 100644 index 0000000..a404e6e --- /dev/null +++ b/tests/Type/Map/AccessTypeTest.php @@ -0,0 +1,27 @@ + 1, 'b' => 2]); + +assertType('int', $map->get('a')); +assertType('int|null', $map->getOrNull('a')); +assertType("'x'|int", $map->getOrDefault('a', 'x')); +assertType("'x'|int", $map->getOrCompute('a', fn (): string => 'x')); +// Array access via [] returns the value (offsetGet throws on a missing key). +assertType('int', $map['a']); + +// Virtual property hooks expose the key/value/entry views. +assertType('Noctud\Collection\Set\Set', $map->keys); +assertType('Noctud\Collection\Collection', $map->values); +assertType('Noctud\Collection\Set\Set>', $map->entries); diff --git a/tests/Type/Map/ConversionTypeTest.php b/tests/Type/Map/ConversionTypeTest.php new file mode 100644 index 0000000..b051cd5 --- /dev/null +++ b/tests/Type/Map/ConversionTypeTest.php @@ -0,0 +1,38 @@ + 1, 'b' => 2]); + +// toArray is conditional: a string|int key stays a native array key. +assertType('array', $map->toArray()); +assertType('array', intMapOf([1 => 'a', 2 => 'b'])->toArray()); +// Non-array-key keys (e.g. objects) fall back to a value-typed array. +assertType('array', listOf(['a'])->toMap(fn (string $i): stdClass => new stdClass())->toArray()); + +// map/mapNotNull/flatMap flatten the map down to an immutable list. +assertType('Noctud\Collection\List\ImmutableList', $map->map(fn (int $v, string $k): bool => $v > 0)); +assertType('Noctud\Collection\List\ImmutableList', $map->mapNotNull(fn (int $v, string $k): ?bool => $v > 0 ? true : null)); +assertType('Noctud\Collection\List\ImmutableList', $map->flatMap(fn (int $v, string $k): array => [$v])); + +assertType('list', $map->toPairs()); +assertType('Noctud\Collection\Map\MutableMap', $map->toMutable()); +assertType('Noctud\Collection\Map\ImmutableMap', $map->toImmutable()); + +// forEach variants return the map unchanged. +assertType('Noctud\Collection\Map\ImmutableMap', $map->forEach(fn (int $v, string $k): null => null)); +assertType('Noctud\Collection\Map\ImmutableMap', $map->forEachKey(fn (string $k): null => null)); +assertType('Noctud\Collection\Map\ImmutableMap', $map->forEachValue(fn (int $v): null => null)); diff --git a/tests/Type/Map/EntryTypeTest.php b/tests/Type/Map/EntryTypeTest.php new file mode 100644 index 0000000..301d86a --- /dev/null +++ b/tests/Type/Map/EntryTypeTest.php @@ -0,0 +1,27 @@ + 1, 'b' => 2]); + +// The entries view of a map is a set of MapEntry. +assertType('Noctud\Collection\Set\Set>', $map->entries); +assertType('Noctud\Collection\Map\MapEntry|null', $map->entries->firstOrNull()); + +// MapEntry exposes key/value through virtual property hooks (generic K/V, not the +// declared `string|int|bool|float|object`/`mixed`). +$entry = $map->entries->firstOrNull(); +if ($entry !== null) { + assertType('string', $entry->key); + assertType('int', $entry->value); +} diff --git a/tests/Type/Map/FactoryTypeTest.php b/tests/Type/Map/FactoryTypeTest.php new file mode 100644 index 0000000..ee28cb0 --- /dev/null +++ b/tests/Type/Map/FactoryTypeTest.php @@ -0,0 +1,20 @@ +', ImmutableHashMap::of(['a' => 1, 'b' => 2])); +assertType('Noctud\Collection\Map\ImmutableMap', ImmutableHashMap::ofPairs([['a', 1], ['b', 2]])); +assertType('Noctud\Collection\Map\MutableMap', MutableHashMap::of(['a' => 1, 'b' => 2])); +assertType('Noctud\Collection\Map\MutableMap', MutableHashMap::ofPairs([['a', 1], ['b', 2]])); diff --git a/tests/Type/Map/MutationTypeTest.php b/tests/Type/Map/MutationTypeTest.php new file mode 100644 index 0000000..512a33e --- /dev/null +++ b/tests/Type/Map/MutationTypeTest.php @@ -0,0 +1,54 @@ + 1, 'b' => 2]); + +// Clean int/bool values derived from the map itself, to observe widening. +$anInt = $imm->get('a'); +$aBool = $imm->isEmpty(); + +// Immutable put/putFirst/putIfAbsent WIDEN both key and value types. +assertType('Noctud\Collection\Map\ImmutableMap', $imm->put($anInt, $aBool)); +assertType('Noctud\Collection\Map\ImmutableMap', $imm->putIfAbsent($anInt, $aBool)); +assertType('Noctud\Collection\Map\ImmutableMap', $imm->putFirst($anInt, $aBool)); +assertType('Noctud\Collection\Map\ImmutableMap', $imm->putAll(['c' => 3])); +assertType('Noctud\Collection\Map\ImmutableMap', $imm->putAllPairs([['c', 3]])); + +// Removals keep both key and value types. +assertType('Noctud\Collection\Map\ImmutableMap', $imm->remove('a')); +assertType('Noctud\Collection\Map\ImmutableMap', $imm->removeFirst()); +assertType('Noctud\Collection\Map\ImmutableMap', $imm->removeLast()); +assertType('Noctud\Collection\Map\ImmutableMap', $imm->removeIf(fn (int $v, string $k): bool => $v > 0)); +assertType('Noctud\Collection\Map\ImmutableMap', $imm->removeIfKey(fn (string $k): bool => $k !== '')); +assertType('Noctud\Collection\Map\ImmutableMap', $imm->removeIfValue(fn (int $v): bool => $v > 0)); +assertType('Noctud\Collection\Map\ImmutableMap', $imm->removeNullValues()); + +// Mutable put/remove are STRICT and return the same MutableMap. +$mut = mutableMapOf(['a' => 1]); +assertType('int', $mut->getOrPut('a', fn (): int => 0)); +assertType('Noctud\Collection\Map\MutableMap', $mut->put('b', 2)); +assertType('Noctud\Collection\Map\MutableMap', $mut->putIfAbsent('c', 3)); +assertType('Noctud\Collection\Map\MutableMap', $mut->putFirst('z', 0)); +assertType('Noctud\Collection\Map\MutableMap', $mut->putAll(['d' => 4])); +assertType('Noctud\Collection\Map\MutableMap', $mut->remove('a')); +assertType('Noctud\Collection\Map\MutableMap', $mut->clear()); +assertType('Noctud\Collection\Map\MutableMap', $mut->sortByKey()); +assertType('Noctud\Collection\Map\MutableMap', $mut->sortByValue()); +assertType('Noctud\Collection\Map\MutableMap', $mut->sortBy(fn (int $v, string $k): int => $v)); +assertType('Noctud\Collection\Map\MutableMap', $mut->sortWithKey(fn (string $a, string $b): int => $a <=> $b)); +assertType('Noctud\Collection\Map\MutableMap', $mut->sortWithValue(fn (int $a, int $b): int => $a <=> $b)); +assertType('Noctud\Collection\Map\MutableMap', $mut->sortWith(fn ($a, $b): int => 0)); +assertType('Noctud\Collection\Map\MutableMap', $mut->reverse()); +assertType('Noctud\Collection\Map\MutableMap', $mut->shuffle()); diff --git a/tests/Type/Map/NarrowingTypeTest.php b/tests/Type/Map/NarrowingTypeTest.php new file mode 100644 index 0000000..e76027a --- /dev/null +++ b/tests/Type/Map/NarrowingTypeTest.php @@ -0,0 +1,70 @@ + 1, 'b' => 2]); + +// Filtering preserves both key and value types and narrows back to ImmutableMap. +assertType('Noctud\Collection\Map\ImmutableMap', $map->filter(fn (int $v, string $k): bool => $v > 0)); +assertType('Noctud\Collection\Map\ImmutableMap', $map->filterKeys(fn (string $k): bool => $k !== '')); +assertType('Noctud\Collection\Map\ImmutableMap', $map->filterValues(fn (int $v): bool => $v > 0)); +assertType('Noctud\Collection\Map\ImmutableMap', $map->filterValuesNotNull()); +assertType('Noctud\Collection\Map\ImmutableMap', stringMapOf(['a' => 1, 'b' => null])->filterValuesNotNull()); +assertType('Noctud\Collection\Map\ImmutableMap', mutableStringMapOf(['a' => 1, 'b' => null])->filterValuesNotNull()); +assertType( + 'Noctud\Collection\Map\ImmutableMap', + $map->filterValuesInstanceOf(stdClass::class), +); + +// mapKeys changes the key type; mapValues changes the value type. +assertType( + 'Noctud\Collection\Map\ImmutableMap', + $map->mapKeys(fn (int $v, string $k): int => $v), +); +assertType( + 'Noctud\Collection\Map\ImmutableMap', + $map->mapValues(fn (int $v, string $k): bool => $v > 0), +); +assertType( + 'Noctud\Collection\Map\ImmutableMap', + $map->mapValuesNotNull(fn (int $v, string $k): ?bool => $v > 0 ? true : null), +); + +// flip swaps keys and values. +assertType('Noctud\Collection\Map\ImmutableMap', $map->flip()); + +// Ordering keeps the key/value types. +assertType('Noctud\Collection\Map\ImmutableMap', $map->sortedByKey()); +assertType('Noctud\Collection\Map\ImmutableMap', $map->sortedByKeyDesc()); +assertType('Noctud\Collection\Map\ImmutableMap', $map->sortedByValue()); +assertType('Noctud\Collection\Map\ImmutableMap', $map->sortedByValueDesc()); +assertType('Noctud\Collection\Map\ImmutableMap', $map->sortedBy(fn (int $v, string $k): int => $v)); +assertType('Noctud\Collection\Map\ImmutableMap', $map->sortedByDesc(fn (int $v, string $k): int => $v)); +assertType('Noctud\Collection\Map\ImmutableMap', $map->sortedWithKey(fn (string $a, string $b): int => $a <=> $b)); +assertType('Noctud\Collection\Map\ImmutableMap', $map->sortedWithValue(fn (int $a, int $b): int => $a <=> $b)); +assertType('Noctud\Collection\Map\ImmutableMap', $map->sortedWith(fn ($a, $b): int => 0)); +assertType('Noctud\Collection\Map\ImmutableMap', $map->reversed()); +assertType('Noctud\Collection\Map\ImmutableMap', $map->shuffled()); + +// Slicing keeps the key/value types. +assertType('Noctud\Collection\Map\ImmutableMap', $map->takeFirst(1)); +assertType('Noctud\Collection\Map\ImmutableMap', $map->takeLast(1)); +assertType('Noctud\Collection\Map\ImmutableMap', $map->dropFirst(1)); +assertType('Noctud\Collection\Map\ImmutableMap', $map->dropLast(1)); +assertType('Noctud\Collection\Map\ImmutableMap', $map->takeWhile(fn (int $v, string $k): bool => $v > 0)); +assertType('Noctud\Collection\Map\ImmutableMap', $map->dropWhile(fn (int $v, string $k): bool => $v > 0)); +assertType('Noctud\Collection\Map\ImmutableMap', $map->takeLastWhile(fn (int $v, string $k): bool => $v > 0)); +assertType('Noctud\Collection\Map\ImmutableMap', $map->dropLastWhile(fn (int $v, string $k): bool => $v > 0)); diff --git a/tests/Type/Map/QueryTypeTest.php b/tests/Type/Map/QueryTypeTest.php new file mode 100644 index 0000000..d05183d --- /dev/null +++ b/tests/Type/Map/QueryTypeTest.php @@ -0,0 +1,19 @@ + 1, 'b' => 2]); + +// countWhere() pins the declared @return int<0, max>. +// count() itself is native-typed (`: int`, no PHPDoc) and needs no type test. +assertType('int<0, max>', $map->countWhere(fn (int $v, string $k): bool => $v > 0)); diff --git a/tests/Type/MapToArrayTypeTest.php b/tests/Type/MapToArrayTypeTest.php deleted file mode 100644 index 4b9cbc5..0000000 --- a/tests/Type/MapToArrayTypeTest.php +++ /dev/null @@ -1,33 +0,0 @@ -', - listOf(['a', 'b', 'c'])->toMap(fn (string $i) => $i)->toArray(), -); -assertType( - 'array', - listOf(['a', 'b'])->toMap(fn (string $i): int => 0)->toArray(), -); - -assertType( - 'array', - listOf(['a', 'b'])->toMap(fn (string $i): int => 0, fn (string $i): int => 1)->toArray(), -); - -assertType( - 'array', - listOf(['a'])->toMap(fn (string $i) => new stdClass())->toArray(), -); diff --git a/tests/Type/Set/MutationTypeTest.php b/tests/Type/Set/MutationTypeTest.php new file mode 100644 index 0000000..7ec039a --- /dev/null +++ b/tests/Type/Set/MutationTypeTest.php @@ -0,0 +1,43 @@ +', $imm->add('x')); +assertType('Noctud\Collection\Set\ImmutableSet', $imm->addFirst('x')); +assertType('Noctud\Collection\Set\ImmutableSet', $imm->addAll(['x'])); + +// Removals keep the element type. +assertType('Noctud\Collection\Set\ImmutableSet', $imm->removeIf(fn (int $x): bool => $x > 0)); +assertType('Noctud\Collection\Set\ImmutableSet', $imm->removeAll([1])); +assertType('Noctud\Collection\Set\ImmutableSet', $imm->removeElement(1)); +assertType('Noctud\Collection\Set\ImmutableSet', $imm->removeFirst()); +assertType('Noctud\Collection\Set\ImmutableSet', $imm->removeLast()); +assertType('Noctud\Collection\Set\ImmutableSet', $imm->retainAll([1])); + +// Mutable mutators are STRICT (@param E) and return the same MutableSet. +$mut = mutableSetOf([1, 2, 3]); +assertType('Noctud\Collection\Set\MutableSet', $mut->add(4)); +assertType('Noctud\Collection\Set\MutableSet', $mut->addFirst(0)); +assertType('Noctud\Collection\Set\MutableSet', $mut->addAll([4, 5])); +assertType('Noctud\Collection\Set\MutableSet', $mut->removeElement(1)); +assertType('Noctud\Collection\Set\MutableSet', $mut->removeIf(fn (int $x): bool => $x > 0)); +assertType('Noctud\Collection\Set\MutableSet', $mut->clear()); +assertType('Noctud\Collection\Set\MutableSet', $mut->sort()); +assertType('Noctud\Collection\Set\MutableSet', $mut->sortBy(fn (int $x): int => $x)); +assertType('Noctud\Collection\Set\MutableSet', $mut->sortWith(fn (int $a, int $b): int => $a <=> $b)); +assertType('Noctud\Collection\Set\MutableSet', $mut->reverse()); +assertType('Noctud\Collection\Set\MutableSet', $mut->shuffle()); diff --git a/tests/Type/Set/NarrowingTypeTest.php b/tests/Type/Set/NarrowingTypeTest.php new file mode 100644 index 0000000..dfbe0a0 --- /dev/null +++ b/tests/Type/Set/NarrowingTypeTest.php @@ -0,0 +1,47 @@ +', $imm->filter(fn (int $x): bool => $x > 0)); +assertType('Noctud\Collection\Set\ImmutableSet', $imm->filterNotNull()); +assertType('Noctud\Collection\Set\ImmutableSet', setOf([1, null])->filterNotNull()); +assertType('Noctud\Collection\Set\ImmutableSet', mutableSetOf([1, null])->filterNotNull()); +assertType('Noctud\Collection\Set\ImmutableSet', $imm->takeFirst(2)); +assertType('Noctud\Collection\Set\ImmutableSet', $imm->distinct()); +assertType('Noctud\Collection\Set\ImmutableSet', $imm->sorted()); +assertType('Noctud\Collection\Set\ImmutableSet', $imm->reversed()); +assertType('Noctud\Collection\Set\Set', $imm->intersect([1, 2])); +assertType('Noctud\Collection\Set\Set', $imm->union([1, 2])); +assertType('Noctud\Collection\Set\Set', $imm->subtract([1, 2])); +assertType('Noctud\Collection\Set\ImmutableSet', $imm->toImmutable()); +assertType( + 'array{Noctud\Collection\Set\ImmutableSet, Noctud\Collection\Set\ImmutableSet}', + $imm->partition(fn (int $x): bool => $x > 0), +); + +// Type-changing transforms move to the new element type but stay ImmutableSet. +assertType('Noctud\Collection\Set\ImmutableSet', $imm->map(fn (int $x): bool => $x > 0)); +assertType('Noctud\Collection\Set\ImmutableSet', $imm->filterInstanceOf(stdClass::class)); + +// The base Set contract keeps everything at the Set interface level. +/** @var Set $set */ +$set = setOf([1, 2, 3]); +assertType('Noctud\Collection\Set\Set', $set->filter(fn (int $x): bool => $x > 0)); +assertType('Noctud\Collection\Set\Set', $set->map(fn (int $x): bool => $x > 0)); +assertType('Noctud\Collection\Set\Set', $set->sorted());