diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..3e73900 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,30 @@ +# Project + +PHP microservices platform. Hexagonal architecture (ports & adapters), DDD, CQRS. + +## Rules + +All coding standards, architecture, naming, testing, documentation, and OpenAPI conventions +are defined in `rules/`. Read the applicable rule files before generating any code or documentation. + +## Commands + +- `make test` — run tests with coverage. +- `make mutation-test` — run mutation testing (Infection). +- `make review` — run lint. +- `make help` — list all available commands. + +## Post-change validation + +After any code change, run `make review`, `make test`, and `make mutation-test`. +If any fails, iterate on the fix while respecting all project rules until all pass. +Never deliver code that breaks lint, tests, or leaves surviving mutants. + +## File formatting + +Every file produced or modified must: + +- Use **LF** line endings. Never CRLF. +- Have no trailing whitespace on any line. +- End with a single trailing newline. +- Have no consecutive blank lines (max one blank line between blocks). diff --git a/.claude/rules/documentation.md b/.claude/rules/documentation.md new file mode 100644 index 0000000..64587c9 --- /dev/null +++ b/.claude/rules/documentation.md @@ -0,0 +1,37 @@ +--- +description: Standards for README files and all project documentation in PHP libraries. +paths: + - "**/*.md" +--- + +# Documentation + +## README + +1. Include an anchor-linked table of contents. +2. Start with a concise one-line description of what the library does. +3. Include a **badges** section (license, build status, coverage, latest version, PHP version). +4. Provide an **Overview** section explaining the problem the library solves and its design philosophy. +5. **Installation** section: Composer command (`composer require vendor/package`). +6. **How to use** section: complete, runnable code examples covering the primary use cases. Each example + includes a brief heading describing what it demonstrates. +7. If the library exposes multiple entry points, strategies, or container types, document each with its own + subsection and example. +8. **FAQ** section: include entries for common pitfalls, non-obvious behaviors, or design decisions that users + frequently ask about. Each entry is a numbered question as heading (e.g., `### 01. Why does X happen?`) + followed by a concise explanation. Only include entries that address real confusion points. +9. **License** and **Contributing** sections at the end. +10. Write strictly in American English. See `rules/code-style.md` American English section for spelling conventions. + +## Structured data + +1. When documenting constructors, factory methods, or configuration options with more than 3 parameters, + use tables with columns: Parameter, Type, Required, Description. +2. Prefer tables to prose for any structured information. + +## Style + +1. Keep language concise and scannable. +2. Never include placeholder content (`TODO`, `TBD`). +3. Code examples must be syntactically correct and self-contained. +4. Do not document `Internal/` classes or private API. Only document what consumers interact with. diff --git a/.claude/rules/github-workflows.md b/.claude/rules/github-workflows.md new file mode 100644 index 0000000..a369ba4 --- /dev/null +++ b/.claude/rules/github-workflows.md @@ -0,0 +1,78 @@ +--- +description: Naming, ordering, inputs, security, and structural rules for all GitHub Actions workflow files. +paths: + - ".github/workflows/**/*.yml" + - ".github/workflows/**/*.yaml" +--- + +# Workflows + +Structural and stylistic rules for GitHub Actions workflow files. Refer to `shell-scripts.md` for Bash conventions used +inside `run:` steps, and to `terraforms.md` for Terraform conventions used in `terraform/`. + +## Pre-output checklist + +Verify every item before producing any workflow YAML. If any item fails, revise before outputting. + +1. File name follows the convention: `ci-.yml` for reusable CI, `cd-.yml` for dispatch CD. +2. `name` field follows the pattern `CI — ` or `CD — `, using sentence case after the dash + (e.g., `CD — Run migration`, not `CD — Run Migration`). +3. Reusable workflows use `workflow_call` trigger. CD workflows use `workflow_dispatch` trigger. +4. Each workflow has a single responsibility. CI tests code. CD deploys it. Never combine both. +5. Every input has a `description` field. Descriptions use American English and end with a period. +6. Input names use `kebab-case`: `service-name`, `dry-run`, `skip-build`. +7. Inputs are ordered: required first, then optional. Each group by **name length ascending**. +8. Choice input options are in **alphabetical order**. +9. `env`, `outputs`, and `with` entries are ordered by **key length ascending**. +10. `permissions` keys are ordered by **key length ascending** (`contents` before `id-token`). +11. Top-level workflow keys follow canonical order: `name`, `on`, `concurrency`, `permissions`, `env`, `jobs`. +12. Job-level properties follow canonical order: `if`, `name`, `needs`, `uses`, `with`, `runs-on`, + `environment`, `timeout-minutes`, `strategy`, `outputs`, `permissions`, `env`, `steps`. +13. All other YAML property names within a block are ordered by **name length ascending**. +14. Jobs follow execution order: `load-config` → `lint` → `test` → `build` → `deploy`. +15. Step names start with a verb and use sentence case: `Setup PHP`, `Run lint`, `Resolve image tag`. +16. Runtime versions are resolved from the service repo's native dependency file (`composer.json`, `go.mod`, + `package.json`). No version is hardcoded in any workflow. +17. Service-specific overrides live in a pipeline config file (e.g., `.pipeline.yml`) in the service repo, + not in the workflows repository. +18. The `load-config` job reads the pipeline config file at runtime with safe fallback to defaults when absent. +19. Top-level `permissions` defaults to read-only (`contents: read`). Jobs escalate only the permissions they + need. +20. AWS authentication uses OIDC federation exclusively. Static access keys are forbidden. +21. Secrets are passed via `secrets: inherit` from callers. No secret is hardcoded. +22. Sensitive values fetched from SSM are masked with `::add-mask::` before assignment. +23. Third-party actions are pinned to the latest available full commit SHA with a version comment: + `uses: aws-actions/configure-aws-credentials@ # v4.0.2`. Always verify the latest + version before generating a workflow. +24. First-party actions (`actions/*`) are pinned to the latest major version tag available: + `actions/checkout@v4`. Always check for the most recent major version before generating a workflow. +25. Production deployments require GitHub Environments protection rules (manual approval). +26. Every job sets `timeout-minutes` to prevent indefinite hangs. CI jobs: 10–15 minutes. CD jobs: 20–30 + minutes. Adjust only with justification in a comment. +27. CI workflows set `concurrency` with `group` scoped to the PR and `cancel-in-progress: true` to avoid + redundant runs. +28. CD workflows set `concurrency` with `group` scoped to the environment and `cancel-in-progress: false` to + prevent interrupted deployments. +29. CD workflows use `if: ${{ !cancelled() }}` to allow to deploy after optional build steps. +30. Inline logic longer than 3 lines is extracted to a script in `scripts/ci/` or `scripts/cd/`. + +## Style + +- All text (workflow names, step names, input descriptions, comments) uses American English with correct + spelling and punctuation. Sentences and descriptions end with a period. + +## Callers + +- Callers trigger on `pull_request` targeting `main` only. No `push` trigger. +- Callers in service repos are static (~10 lines) and pass only `service-name` or `app-name`. +- Callers reference workflows with `@main` during development. Pin to a tag or SHA for production. + +## Image tagging + +- CD deploy builds: `-sha-` + `latest`. + +## Migrations + +- Migrations run **before** service deployment (schema first, code second). +- `cd-migrate.yml` supports `dry-run` mode (`flyway validate`) for pre-flight checks. +- Database credentials are fetched from SSM at runtime, never stored in workflow files. diff --git a/.claude/rules/php-code-style.md b/.claude/rules/php-code-style.md new file mode 100644 index 0000000..59323ba --- /dev/null +++ b/.claude/rules/php-code-style.md @@ -0,0 +1,121 @@ +--- +description: Pre-output checklist, naming, typing, comparisons, and PHPDoc rules for all PHP files in libraries. +paths: + - "src/**/*.php" + - "tests/**/*.php" +--- + +# Code style + +Semantic code rules for all PHP files. Formatting rules (PSR-1, PSR-4, PSR-12, line length) are enforced by `phpcs.xml` +and are not repeated here. Refer to `rules/domain.md` for domain modeling rules. + +## Pre-output checklist + +Verify every item before producing any PHP code. If any item fails, revise before outputting. + +1. `declare(strict_types=1)` is present. +2. All classes are `final readonly` by default. Use `class` (without `final` or `readonly`) only when the class is + designed as an extension point for consumers (e.g., `Collection`, `ValueObject`). Use `final class` without + `readonly` only when the parent class is not readonly (e.g., extending a third-party abstract class). +3. All parameters, return types, and properties have explicit types. +4. Constructor property promotion is used. +5. Named arguments are used at call sites for own code, tests, and third-party library methods (e.g., tiny-blocks). + Never use named arguments on native PHP functions (`array_map`, `in_array`, `preg_match`, `is_null`, + `iterator_to_array`, `sprintf`, `implode`, etc.) or PHPUnit assertions (`assertEquals`, `assertSame`, + `assertTrue`, `expectException`, etc.). +6. No `else` or `else if` exists anywhere. Use early returns, polymorphism, or map dispatch instead. +7. No abbreviations appear in identifiers. Use `$index` instead of `$i`, `$account` instead of `$acc`. +8. No generic identifiers exist. Use domain-specific names instead: + `$data` → `$payload`, `$value` → `$totalAmount`, `$item` → `$element`, + `$info` → `$currencyDetails`, `$result` → `$conversionOutcome`. +9. No raw arrays exist where a typed collection or value object is available. Use `tiny-blocks/collection` + (`Collection`, `Collectible`) instead of raw `array` for any list of domain objects. Raw arrays are acceptable + only for primitive configuration data, variadic pass-through, or interop at system boundaries. +10. No private methods exist except private constructors for factory patterns. Inline trivial logic at the call site + or extract it to a collaborator or value object. +11. Members are ordered: constants first, then constructor, then static methods, then instance methods. Within each + group, order by body size ascending (number of lines between `{` and `}`). Constants and enum cases, which have + no body, are ordered by name length ascending. +12. Constructor parameters are ordered by parameter name length ascending (count the name only, without `$` or type), + except when parameters have an implicit semantic order (e.g., `$start/$end`, `$from/$to`, `$startAt/$endAt`), + which takes precedence. The same rule applies to named arguments at call sites. + Example: `$id` (2) → `$value` (5) → `$status` (6) → `$precision` (9). +13. No O(N²) or worse complexity exists. +14. No logic is duplicated across two or more places (DRY). +15. No abstraction exists without real duplication or isolation need (KISS). +16. All identifiers, comments, and documentation are written in American English. +17. No justification comments exist (`// NOTE:`, `// REASON:`, etc.). Code speaks for itself. +18. `// TODO: ` is used when implementation is unknown, uncertain, or intentionally deferred. + Never leave silent gaps. +19. All class references use `use` imports at the top of the file. Fully qualified names inline are prohibited. +20. No dead or unused code exists. Remove unreferenced classes, methods, constants, and imports. +21. Never create public methods, constants, or classes in `src/` solely to serve tests. If production code does not + need it, it does not exist. +22. Always use the most current and clean syntax available in the target PHP version. Prefer match to switch, + first-class callables over `Closure::fromCallable()`, readonly promotion over manual assignment, enum methods + over external switch/if chains, named arguments over positional ambiguity (except where excluded by rule 5), + and `Collection::map` over foreach accumulation. +23. No vertical alignment of types in parameter lists or property declarations. Use a single space between + type and variable name. Never pad with extra spaces to align columns: + `public OrderId $id` — not `public OrderId $id`. +24. Opening brace `{` goes on the same line as the closing parenthesis `)` for constructors, methods, and + closures: `): ReturnType {` — not `): ReturnType\n {`. Parameters with default values go last. + +## Casing conventions + +- Internal code (variables, methods, classes): **`camelCase`**. +- Constants and enum-backed values when representing codes: **`SCREAMING_SNAKE_CASE`**. + +## Naming + +- Names describe **what** in domain terms, not **how** technically: `$monthlyRevenue` instead of `$calculatedValue`. +- Generic technical verbs (`process`, `handle`, `execute`, `mark`, `enforce`, `manage`, `ensure`, `validate`, + `check`, `verify`, `assert`, `transform`, `parse`, `compute`, `sanitize`, `normalize`) **should be avoided**. + Prefer names that describe the domain operation. +- Booleans use predicate form: `isActive`, `hasPermission`, `wasProcessed`. +- Collections are always plural: `$orders`, `$lines`. +- Methods returning bool use prefixes: `is`, `has`, `can`, `was`, `should`. + +## Comparisons + +1. Null checks: use `is_null($variable)`, never `$variable === null`. +2. Empty string checks on typed `string` parameters: use `$variable === ''`. Avoid `empty()` on typed strings + because `empty('0')` returns `true`. +3. Mixed or untyped checks (value may be `null`, empty string, `0`, or `false`): use `empty($variable)`. + +## American English + +All identifiers, enum values, comments, and error codes use American English spelling: +`canceled` (not `cancelled`), `organization` (not `organisation`), `initialize` (not `initialise`), +`behavior` (not `behaviour`), `modeling` (not `modelling`), `labeled` (not `labelled`), +`fulfill` (not `fulfil`), `color` (not `colour`). + +## PHPDoc + +- PHPDoc is restricted to interfaces only, documenting obligations and `@throws`. +- Never add PHPDoc to concrete classes. + +## Collection usage + +When a property or parameter is `Collectible`, use its fluent API. Never break out to raw array functions. + +**Prohibited — `array_map` + `iterator_to_array` on a Collectible:** + +```php +$names = array_map( + static fn(Element $element): string => $element->name(), + iterator_to_array($collection) +); +``` + +**Correct — fluent chain with `map()` + `toArray()`:** + +```php +$names = $collection + ->map(transformations: static fn(Element $element): string => $element->name()) + ->toArray(keyPreservation: KeyPreservation::DISCARD); +``` + +The same applies to `filter()`, `reduce()`, `each()`, and all other `Collectible` operations. Chain them +fluently. Never materialize with `iterator_to_array` to then pass into a raw `array_*` function. diff --git a/.claude/rules/php-domain.md b/.claude/rules/php-domain.md new file mode 100644 index 0000000..f3b0eea --- /dev/null +++ b/.claude/rules/php-domain.md @@ -0,0 +1,96 @@ +--- +description: Domain modeling rules for PHP libraries — folder structure, naming, value objects, exceptions, enums, and SOLID. +paths: + - "src/**/*.php" +--- + +# Domain modeling + +Libraries are self-contained packages. The core has no dependency on frameworks, databases, or I/O. +Refer to `rules/code-style.md` for the pre-output checklist applied to all PHP code. + +## Folder structure + +``` +src/ +├── .php # Primary contract for consumers +├── .php # Main implementation or extension point +├── .php # Public enum +├── Contracts/ # Interfaces for data returned to consumers +├── Internal/ # Implementation details (not part of public API) +│ ├── .php +│ └── Exceptions/ # Internal exception classes +├── / # Feature-specific subdirectory when needed +└── Exceptions/ # Public exception classes (when part of the API) +``` + +**Public API boundary:** Only interfaces, extension points, enums, and thin orchestration classes live at the +`src/` root. These classes define the contract consumers interact with and delegate all real work to collaborators +inside `src/Internal/`. If a class contains substantial logic (algorithms, state machines, I/O), it belongs in +`Internal/`, not at the root. + +The `Internal/` namespace signals classes that are implementation details. Consumers must not depend on them. +Never use `Entities/`, `ValueObjects/`, `Enums/`, or `Domain/` as folder names. + +## Nomenclature + +1. Every class, property, method, and exception name reflects the **domain concept** the library represents. + A math library uses `Precision`, `RoundingMode`; a money library uses `Currency`, `Amount`; a collection + library uses `Collectible`, `Order`. +2. Never use generic technical names: `Manager`, `Helper`, `Processor`, `Data`, `Info`, `Utils`, + `Item`, `Record`, `Entity`, `Exception`, `Ensure`, `Validate`, `Check`, `Verify`, + `Assert`, `Transform`, `Parse`, `Compute`, `Sanitize`, or `Normalize` as class suffixes or prefixes. +3. Name classes after what they represent: `Money`, `Color`, `Pipeline` — not after what they do technically. +4. Name methods after the operation in domain terms: `add()`, `convertTo()`, `splitAt()` — not `process()`, + `handle()`, `execute()`, `manage()`, `ensure()`, `validate()`, `check()`, `verify()`, `assert()`, + `transform()`, `parse()`, `compute()`, `sanitize()`, or `normalize()`. + +## Value objects + +1. Are immutable: no setters, no mutation after construction. Operations return new instances. +2. Compare by value, not by reference. +3. Validate invariants in the constructor and throw on invalid input. +4. Have no identity field. +5. Use static factory methods (e.g., `from`, `of`, `zero`) with a private constructor when multiple creation + paths exist. + +## Exceptions + +1. Extend native PHP exceptions (`DomainException`, `InvalidArgumentException`, `OverflowException`, etc.). +2. Are pure: no formatted `code`/`message` for HTTP responses. +3. Signal invariant violations only. +4. Name after the invariant violated, never after the technical type: + `PrecisionOutOfRange` — not `InvalidPrecisionException`. + `CurrencyMismatch` — not `BadCurrencyException`. + `ContainerWaitTimeout` — not `TimeoutException`. +5. Create the exception class directly with the invariant name and the appropriate native parent. The exception + is dedicated by definition when its name describes the specific invariant it guards. + +## Enums + +1. Are PHP backed enums. +2. Include domain-meaningful methods when needed (e.g., `Order::ASCENDING_KEY`). + +## Extension points + +1. When a class is designed to be extended by consumers (e.g., `Collection`, `ValueObject`), it uses `class` + instead of `final readonly class`. All other classes use `final readonly class`. +2. Extension point classes use a private constructor with static factory methods (`createFrom`, `createFromEmpty`) + as the only creation path. +3. Internal state is injected via the constructor and stored in a `private readonly` property. + +## Principles + +- **Immutability**: all models and value objects adopt immutability. Operations return new instances. +- **Zero dependencies**: the library's core has no dependency on frameworks, databases, or I/O. +- **Small surface area**: expose only what consumers need. Hide implementation in `Internal/`. + +## SOLID reference + +| Principle | Failure signal | +|---------------------------|---------------------------------------------| +| S — Single responsibility | Class does two unrelated things | +| O — Open/closed | Adding a feature requires editing internals | +| L — Liskov substitution | Subclass throws on parent method | +| I — Interface segregation | Interface has unused methods | +| D — Dependency inversion | Constructor uses `new ConcreteClass()` | diff --git a/.claude/rules/php-testing.md b/.claude/rules/php-testing.md new file mode 100644 index 0000000..7bd9e68 --- /dev/null +++ b/.claude/rules/php-testing.md @@ -0,0 +1,120 @@ +--- +description: BDD Given/When/Then structure, PHPUnit conventions, test organization, and fixture rules for PHP libraries. +paths: + - "tests/**/*.php" +--- + +# Testing conventions + +Framework: **PHPUnit**. Refer to `rules/code-style.md` for the code style checklist, which also applies to test files. + +## Structure: Given/When/Then (BDD) + +Every test uses `/** @Given */`, `/** @And */`, `/** @When */`, `/** @Then */` doc comments without exception. + +### Happy path example + +```php +public function testAddMoneyWhenSameCurrencyThenAmountsAreSummed(): void +{ + /** @Given two money instances in the same currency */ + $ten = Money::of(amount: 1000, currency: Currency::BRL); + $five = Money::of(amount: 500, currency: Currency::BRL); + + /** @When adding them together */ + $total = $ten->add(other: $five); + + /** @Then the result contains the sum of both amounts */ + self::assertEquals(expected: 1500, actual: $total->amount()); +} +``` + +### Exception example + +When testing that an exception is thrown, place `@Then` (expectException) **before** `@When`. PHPUnit requires this +ordering. + +```php +public function testAddMoneyWhenDifferentCurrenciesThenCurrencyMismatch(): void +{ + /** @Given two money instances in different currencies */ + $brl = Money::of(amount: 1000, currency: Currency::BRL); + $usd = Money::of(amount: 500, currency: Currency::USD); + + /** @Then an exception indicating currency mismatch should be thrown */ + $this->expectException(CurrencyMismatch::class); + + /** @When trying to add money with different currencies */ + $brl->add(other: $usd); +} +``` + +Use `@And` for complementary preconditions or actions within the same scenario, avoiding consecutive `@Given` or +`@When` tags. + +## Rules + +1. Include exactly one `@When` per test. Two actions require two tests. +2. Test only the public API. Never assert on private state or `Internal/` classes directly. +3. Never mock internal collaborators. Use real objects. Use test doubles only at system boundaries (filesystem, + clock, network) when the library interacts with external resources. +4. Name tests to describe behavior, not method names. +5. Never include conditional logic inside tests. +6. Include one logical concept per `@Then` block. +7. Maintain strict independence between tests. No inherited state. +8. For exception tests, place `@Then` (expectException) before `@When`. +9. Use domain-specific model classes in `tests/Models/` for test fixtures that represent domain concepts + (e.g., `Amount`, `Invoice`, `Order`). +10. Use mock classes in `tests/Mocks/` (or `tests/Unit/Mocks/`) for test doubles of system boundaries + (e.g., `ClientMock`, `ExecutionCompletedMock`). +11. Exercise invariants and edge cases through the library's public entry point. Create a dedicated test class + for an internal model only when the condition cannot be reached through the public API. +12. Never use `/** @test */` annotation. Test methods are discovered by the `test` prefix in the method name. +13. Never use named arguments on PHPUnit assertions (`assertEquals`, `assertSame`, `assertTrue`, + `expectException`, etc.). Pass arguments positionally. + +## Test setup and fixtures + +1. **One annotation = one statement.** Each `@Given` or `@And` block contains exactly one annotation line + followed by one expression or assignment. Never place multiple variable declarations or object + constructions under a single annotation. +2. **No intermediate variables used only once.** If a value is consumed in a single place, inline it at the + call site. Chain method calls when the intermediate state is not referenced elsewhere + (e.g., `Money::of(...)->add(...)` instead of `$money = Money::of(...); $money->add(...);`). +3. **No private or helper methods in test classes.** The only non-test methods allowed are data providers. + If setup logic is complex enough to extract, it belongs in a dedicated fixture class, not in a + private method on the test class. +4. **Domain terms in variables and annotations.** Never use technical testing jargon (`$spy`, `$mock`, + `$stub`, `$fake`, `$dummy`) as variable or property names. Use the domain concept the object + represents: `$collection`, `$amount`, `$currency`, `$sortedElements`. Class names like + `ClientMock` or `GatewaySpy` are acceptable — the variable holding the instance is what matters. +5. **Annotations use domain language.** Write `/** @Given a collection of amounts */`, not + `/** @Given a mocked collection in test state */`. The annotation describes the domain + scenario, not the technical setup. + +## Test organization + +``` +tests/ +├── Models/ # Domain-specific fixtures reused across tests +├── Mocks/ # Test doubles for system boundaries +├── Unit/ # Unit tests for public API +│ └── Mocks/ # Alternative location for test doubles +├── Integration/ # Tests requiring real external resources (Docker, filesystem) +└── bootstrap.php # Test bootstrap when needed +``` + +- `tests/` or `tests/Unit/`: pure unit tests exercising the library's public API. +- `tests/Integration/`: tests requiring real external resources (e.g., Docker containers, databases). + Only present when the library interacts with infrastructure. +- `tests/Models/`: domain-specific fixture classes reused across test files. +- `tests/Mocks/` or `tests/Unit/Mocks/`: test doubles for system boundaries. + +## Coverage and mutation testing + +1. Line and branch coverage must be **100%**. No annotations (`@codeCoverageIgnore`), attributes, or configuration + that exclude code from coverage are allowed. +2. All mutations reported by Infection must be **killed**. Never ignore or suppress mutants via `infection.json.dist` + or any other mechanism. +3. If a line or mutation cannot be covered or killed, it signals a design problem in the production code. Refactor + the code to make it testable, do not work around the tool. diff --git a/.gitattributes b/.gitattributes index 8c85471..28337dc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,13 +1,30 @@ -/tests export-ignore -/vendor export-ignore - -/LICENSE export-ignore -/Makefile export-ignore -/README.md export-ignore -/phpunit.xml export-ignore -/phpstan.neon.dist export-ignore -/infection.json.dist export-ignore - -/.github export-ignore -/.gitignore export-ignore -/.gitattributes export-ignore +# Auto detect text files and perform LF normalization +* text=auto eol=lf + +# ─── Diff drivers ──────────────────────────────────────────── +*.php diff=php +*.md diff=markdown + +# ─── Force LF ──────────────────────────────────────────────── +*.sh text eol=lf +Makefile text eol=lf + +# ─── Generated (skip diff and GitHub stats) ────────────────── +composer.lock -diff linguist-generated + +# ─── Export ignore (excluded from dist archive) ────────────── +/tests export-ignore +/vendor export-ignore +/rules export-ignore + +/.github export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore + +/CLAUDE.md export-ignore +/LICENSE export-ignore +/Makefile export-ignore +/README.md export-ignore +/phpunit.xml export-ignore +/phpstan.neon.dist export-ignore +/infection.json.dist export-ignore \ No newline at end of file diff --git a/README.md b/README.md index b96abea..4bf1eb2 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,23 @@ final class Invoices extends Collection
-### Writing +#### Creating from a closure + +The `createLazyFromClosure` method creates a lazy collection backed by a closure that produces an iterable. The +closure is invoked each time the collection is iterated, enabling safe re-iteration over generators or other single-use +iterables. + +```php +use TinyBlocks\Collection\Collection; + +$collection = Collection::createLazyFromClosure(factory: static function (): iterable { + yield 1; + yield 2; + yield 3; +}); +``` + +## Writing These methods enable adding, removing, and modifying elements in the Collection. @@ -325,7 +341,8 @@ These methods allow the Collection's elements to be transformed or converted int #### Applying actions without modifying elements - `each`: Executes actions on each element in the Collection without modification. - The method is helpful for performing side effects, such as logging or accumulating values. + This is a terminal operation that does not return the collection. It is useful for performing side effects, such as + logging or accumulating values. ```php $collection->each(actions: static fn(Amount $amount): void => $total += $amount->value); @@ -420,7 +437,8 @@ recreate the `Collection`. - **Eager evaluation** (`createFrom` / `createFromEmpty`): Elements are materialized immediately into an array, enabling constant-time access by index, count, and repeated iteration. -- **Lazy evaluation** (`createLazyFrom` / `createLazyFromEmpty`): Elements are processed on-demand through generators, +- **Lazy evaluation** (`createLazyFrom` / `createLazyFromEmpty` / `createLazyFromClosure`): Elements are processed + on-demand through generators, consuming memory only as each element is yielded. Ideal for large datasets or pipelines where not all elements need to be materialized. diff --git a/src/Collectible.php b/src/Collectible.php index 993800b..1b09305 100644 --- a/src/Collectible.php +++ b/src/Collectible.php @@ -57,6 +57,17 @@ public static function createLazyFrom(iterable $elements): static; */ public static function createLazyFromEmpty(): static; + /** + * Creates a collection using lazy evaluation from a closure that produces an iterable. + * + * The closure is invoked each time the collection is iterated, enabling + * safe re-iteration over generators or other single-use iterables. + * + * @param Closure $factory A closure returning an iterable of elements. + * @return static A new collection backed by the given factory. + */ + public static function createLazyFromClosure(Closure $factory): static; + /** * Returns a new collection with the specified elements appended. * @@ -102,10 +113,11 @@ public function findBy(Closure ...$predicates): mixed; /** * Executes side effect actions on every element without modifying the collection. * + * This is a terminal operation. The collection is not returned. + * * @param Closure ...$actions Actions to perform on each element. - * @return static The same instance, enabling further chaining. */ - public function each(Closure ...$actions): static; + public function each(Closure ...$actions): void; /** * Compares this collection with another for element-wise equality. diff --git a/src/Collection.php b/src/Collection.php index 0533484..1f0e90d 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -7,7 +7,6 @@ use Closure; use TinyBlocks\Collection\Internal\EagerPipeline; use TinyBlocks\Collection\Internal\LazyPipeline; -use TinyBlocks\Collection\Internal\Operations\Operation; use TinyBlocks\Collection\Internal\Operations\Resolving\Each; use TinyBlocks\Collection\Internal\Operations\Resolving\Equality; use TinyBlocks\Collection\Internal\Operations\Resolving\Find; @@ -30,14 +29,6 @@ use TinyBlocks\Mapper\IterableMapper; use Traversable; -/** - * Extensible, type-safe collection with a fluent API. - * - * Designed as the primary extension point — domain collections should - * extend this class to inherit all collection behavior: - * - * final class Orders extends Collection { } - */ class Collection implements Collectible, IterableMapper { use IterableMappability; @@ -66,6 +57,11 @@ public static function createLazyFromEmpty(): static return static::createLazyFrom(elements: []); } + public static function createLazyFromClosure(Closure $factory): static + { + return new static(pipeline: LazyPipeline::fromClosure(factory: $factory)); + } + public function getIterator(): Traversable { yield from $this->pipeline->process(); @@ -73,12 +69,12 @@ public function getIterator(): Traversable public function add(mixed ...$elements): static { - return $this->pipeTo(operation: Add::these(newElements: $elements)); + return new static(pipeline: $this->pipeline->pipe(operation: Add::these(newElements: $elements))); } public function merge(Collectible $other): static { - return $this->pipeTo(operation: Merge::with(other: $other)); + return new static(pipeline: $this->pipeline->pipe(operation: Merge::with(other: $other))); } public function contains(mixed $element): bool @@ -96,11 +92,9 @@ public function findBy(Closure ...$predicates): mixed return Find::firstMatch(elements: $this, predicates: $predicates); } - public function each(Closure ...$actions): static + public function each(Closure ...$actions): void { Each::execute(elements: $this, actions: $actions); - - return $this; } public function equals(Collectible $other): bool @@ -110,17 +104,17 @@ public function equals(Collectible $other): bool public function remove(mixed $element): static { - return $this->pipeTo(operation: Remove::element(element: $element)); + return new static(pipeline: $this->pipeline->pipe(operation: Remove::element(element: $element))); } public function removeAll(?Closure $predicate = null): static { - return $this->pipeTo(operation: RemoveAll::matching(predicate: $predicate)); + return new static(pipeline: $this->pipeline->pipe(operation: RemoveAll::matching(predicate: $predicate))); } public function filter(?Closure ...$predicates): static { - return $this->pipeTo(operation: Filter::matching(...$predicates)); + return new static(pipeline: $this->pipeline->pipe(operation: Filter::matching(...$predicates))); } public function first(mixed $defaultValueIfNotFound = null): mixed @@ -130,7 +124,7 @@ public function first(mixed $defaultValueIfNotFound = null): mixed public function flatten(): static { - return $this->pipeTo(operation: FlatMap::oneLevel()); + return new static(pipeline: $this->pipeline->pipe(operation: FlatMap::oneLevel())); } public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed @@ -140,7 +134,7 @@ public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed public function groupBy(Closure $classifier): static { - return $this->pipeTo(operation: GroupInto::by(classifier: $classifier)); + return new static(pipeline: $this->pipeline->pipe(operation: GroupInto::by(classifier: $classifier))); } public function isEmpty(): bool @@ -160,7 +154,7 @@ public function last(mixed $defaultValueIfNotFound = null): mixed public function map(Closure ...$transformations): static { - return $this->pipeTo(operation: Map::using(...$transformations)); + return new static(pipeline: $this->pipeline->pipe(operation: Map::using(...$transformations))); } public function reduce(Closure $accumulator, mixed $initial): mixed @@ -170,16 +164,13 @@ public function reduce(Closure $accumulator, mixed $initial): mixed public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $comparator = null): static { - return $this->pipeTo(operation: Rearrange::by(order: $order, comparator: $comparator)); - } + $operation = Rearrange::by(order: $order, comparator: $comparator); - public function slice(int $offset, int $length = -1): static - { - return $this->pipeTo(operation: Segment::from(offset: $offset, length: $length)); + return new static(pipeline: $this->pipeline->pipe(operation: $operation)); } - private function pipeTo(Operation $operation): static + public function slice(int $offset, int $length = -1): static { - return new static(pipeline: $this->pipeline->pipe(operation: $operation)); + return new static(pipeline: $this->pipeline->pipe(operation: Segment::from(offset: $offset, length: $length))); } } diff --git a/src/Internal/EagerPipeline.php b/src/Internal/EagerPipeline.php index 7dff0bc..8cbb9c1 100644 --- a/src/Internal/EagerPipeline.php +++ b/src/Internal/EagerPipeline.php @@ -7,13 +7,6 @@ use Generator; use TinyBlocks\Collection\Internal\Operations\Operation; -/** - * Array-backed pipeline with immediate evaluation. - * - * Each operation materializes results into an array immediately, - * enabling constant-time access, count, and repeated iteration. - * Ideal for small to medium datasets and random access scenarios. - */ final readonly class EagerPipeline implements Pipeline { private function __construct(private array $elements) @@ -22,14 +15,14 @@ private function __construct(private array $elements) public static function from(iterable $source): EagerPipeline { - $elements = is_array($source) ? $source : iterator_to_array(iterator: $source); + $elements = is_array($source) ? $source : iterator_to_array($source); return new EagerPipeline(elements: $elements); } public function pipe(Operation $operation): Pipeline { - $elements = iterator_to_array(iterator: $operation->apply(elements: $this->elements)); + $elements = iterator_to_array($operation->apply(elements: $this->elements)); return new EagerPipeline(elements: $elements); } diff --git a/src/Internal/LazyPipeline.php b/src/Internal/LazyPipeline.php index a8cd6dc..0724a87 100644 --- a/src/Internal/LazyPipeline.php +++ b/src/Internal/LazyPipeline.php @@ -4,24 +4,16 @@ namespace TinyBlocks\Collection\Internal; +use Closure; use Generator; use TinyBlocks\Collection\Internal\Operations\Operation; -/** - * Generator-based pipeline with deferred evaluation. - * - * Operations are accumulated as stages and executed only when - * the pipeline is consumed. Ideal for large or unbounded datasets. - */ final readonly class LazyPipeline implements Pipeline { /** @var Operation[] */ private array $stages; - /** - * @param Operation[] $stages - */ - private function __construct(private iterable $source, array $stages = []) + private function __construct(private iterable|Closure $source, array $stages = []) { $this->stages = $stages; } @@ -31,6 +23,11 @@ public static function from(iterable $source): LazyPipeline return new LazyPipeline(source: $source); } + public static function fromClosure(Closure $factory): LazyPipeline + { + return new LazyPipeline(source: $factory); + } + public function pipe(Operation $operation): Pipeline { $stages = $this->stages; @@ -57,7 +54,9 @@ public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed public function process(): Generator { - $elements = $this->source; + $elements = $this->source instanceof Closure + ? ($this->source)() + : $this->source; foreach ($this->stages as $stage) { $elements = $stage->apply(elements: $elements); diff --git a/src/Internal/Operations/Resolving/Equality.php b/src/Internal/Operations/Resolving/Equality.php index 30c68e0..53d316e 100644 --- a/src/Internal/Operations/Resolving/Equality.php +++ b/src/Internal/Operations/Resolving/Equality.php @@ -33,8 +33,13 @@ public static function exists(iterable $elements, mixed $element): bool public static function compareAll(iterable $elements, Collectible $other): bool { - $iteratorA = Equality::toGenerator(iterable: $elements); - $iteratorB = Equality::toGenerator(iterable: $other); + $iteratorA = (static function () use ($elements): Generator { + yield from $elements; + })(); + + $iteratorB = (static function () use ($other): Generator { + yield from $other; + })(); while ($iteratorA->valid() && $iteratorB->valid()) { if (!Equality::areSame(element: $iteratorA->current(), other: $iteratorB->current())) { @@ -47,9 +52,4 @@ public static function compareAll(iterable $elements, Collectible $other): bool return !$iteratorA->valid() && !$iteratorB->valid(); } - - private static function toGenerator(iterable $iterable): Generator - { - yield from $iterable; - } } diff --git a/src/Internal/Operations/Transforming/Filter.php b/src/Internal/Operations/Transforming/Filter.php index debb77b..cb3c025 100644 --- a/src/Internal/Operations/Transforming/Filter.php +++ b/src/Internal/Operations/Transforming/Filter.php @@ -19,8 +19,8 @@ private function __construct(?Closure ...$predicates) $this->compiledPredicate = match (count($filtered)) { 0 => static fn(mixed $value, mixed $key): bool => (bool)$value, default => static fn(mixed $value, mixed $key): bool => array_all( - array: $filtered, - callback: static fn(Closure $predicate): bool => $predicate($value, $key) + $filtered, + static fn(Closure $predicate): bool => $predicate($value, $key) ), }; } diff --git a/src/Internal/Operations/Transforming/Rearrange.php b/src/Internal/Operations/Transforming/Rearrange.php index e36bb30..6b573c0 100644 --- a/src/Internal/Operations/Transforming/Rearrange.php +++ b/src/Internal/Operations/Transforming/Rearrange.php @@ -22,14 +22,14 @@ public static function by(Order $order, ?Closure $comparator = null): Rearrange public function apply(iterable $elements): Generator { - $materialized = is_array($elements) ? $elements : iterator_to_array($elements, true); + $materialized = is_array($elements) ? $elements : iterator_to_array($elements); $comparator = $this->comparator ?? static fn(mixed $first, mixed $second): int => $first <=> $second; match ($this->order) { Order::ASCENDING_KEY => ksort($materialized), - Order::DESCENDING_KEY => krsort($materialized), + Order::DESCENDING_KEY => krsort($materialized), Order::ASCENDING_VALUE => uasort($materialized, $comparator), Order::DESCENDING_VALUE => uasort( $materialized, diff --git a/src/Order.php b/src/Order.php index 11aa1ac..926d012 100644 --- a/src/Order.php +++ b/src/Order.php @@ -4,32 +4,10 @@ namespace TinyBlocks\Collection; -/** - * Defines the sorting strategy applied to a collection. - * - * Key-based strategies sort by the element's key (index). - * Value-based strategies sort by the element's value using - * a comparator or the spaceship operator as default. - */ enum Order { - /** - * Sorts elements by key in ascending order. - */ case ASCENDING_KEY; - - /** - * Sorts elements by key in descending order. - */ case DESCENDING_KEY; - - /** - * Sorts elements by value in ascending order. - */ case ASCENDING_VALUE; - - /** - * Sorts elements by value in descending order. - */ case DESCENDING_VALUE; } diff --git a/tests/CollectionTest.php b/tests/CollectionTest.php index 0453aa1..df7d043 100644 --- a/tests/CollectionTest.php +++ b/tests/CollectionTest.php @@ -605,4 +605,62 @@ public function testConcatCarriersCollections(): void $all->toArray(keyPreservation: KeyPreservation::DISCARD) ); } + + public function testClosureAndLazyAndEagerProduceSameResults(): 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 closure-backed collection */ + $closureResult = Collection::createLazyFromClosure(factory: static function () use ($elements): array { + return $elements; + }) + ->filter(predicates: $filter) + ->map(transformations: $map) + ->sort(order: SortOrder::ASCENDING_VALUE) + ->toArray(keyPreservation: KeyPreservation::DISCARD); + + /** @And applying the same operations 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 all three should produce identical arrays */ + self::assertSame($closureResult, $lazyResult); + self::assertSame($closureResult, $eagerResult); + } + + public function testClosureBackedCarriersPreservesType(): void + { + /** @Given a closure-backed Carriers collection */ + $carriers = Carriers::createLazyFromClosure(factory: static function (): array { + return ['dhl', 'fedex', 'ups']; + }); + + /** @When mapping to uppercase */ + $actual = $carriers->map( + transformations: static fn(string $name): string => strtoupper($name) + ); + + /** @Then the result should still be an instance of Carriers */ + self::assertInstanceOf(Carriers::class, $actual); + + /** @And the carriers should be uppercased */ + self::assertSame(['DHL', 'FEDEX', 'UPS'], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } } diff --git a/tests/EagerCollectionTest.php b/tests/EagerCollectionTest.php index f8de439..40f7079 100644 --- a/tests/EagerCollectionTest.php +++ b/tests/EagerCollectionTest.php @@ -58,6 +58,28 @@ public function testFromGenerator(): void self::assertSame(3, $collection->count()); } + public function testFromGeneratorReiteratesSuccessfully(): 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); + + /** @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 testAdd(): void { /** @Given an eager collection with three elements */ @@ -233,15 +255,12 @@ public function testEach(): void $sum = 0; /** @When using each to accumulate the sum */ - $actual = $collection->each(actions: function (int $value) use (&$sum): void { + $collection->each(actions: function (int $value) use (&$sum): void { $sum += $value; }); /** @Then the sum should be 6 */ self::assertSame(6, $sum); - - /** @And the returned collection should be the same instance */ - self::assertSame($collection, $actual); } public function testEqualsWithIdenticalCollections(): void @@ -985,7 +1004,7 @@ public function testChainedOperationsWithObjects(): void /** @And a variable to accumulate the total discounted value */ $totalDiscounted = 0.0; - /** @When chaining filter, map, removeAll, sort and each */ + /** @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( @@ -996,10 +1015,12 @@ public function testChainedOperationsWithObjects(): void ->sort( order: Order::ASCENDING_VALUE, comparator: static fn(Amount $first, Amount $second): int => $first->value <=> $second->value - ) - ->each(actions: function (Amount $amount) use (&$totalDiscounted): void { - $totalDiscounted += $amount->value; - }); + ); + + /** @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); diff --git a/tests/LazyCollectionTest.php b/tests/LazyCollectionTest.php index dfe324a..3d1c409 100644 --- a/tests/LazyCollectionTest.php +++ b/tests/LazyCollectionTest.php @@ -233,15 +233,12 @@ public function testEach(): void $sum = 0; /** @When using each to accumulate the sum */ - $actual = $collection->each(actions: function (int $value) use (&$sum): void { + $collection->each(actions: function (int $value) use (&$sum): void { $sum += $value; }); /** @Then the sum should be 6 */ self::assertSame(6, $sum); - - /** @And the returned collection should be the same instance */ - self::assertSame($collection, $actual); } public function testEqualsWithIdenticalCollections(): void @@ -985,7 +982,7 @@ public function testChainedOperationsWithObjects(): void /** @And a variable to accumulate the total discounted value */ $totalDiscounted = 0.0; - /** @When chaining filter, map, removeAll, sort and each */ + /** @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( @@ -996,10 +993,12 @@ public function testChainedOperationsWithObjects(): void ->sort( order: Order::ASCENDING_VALUE, comparator: static fn(Amount $first, Amount $second): int => $first->value <=> $second->value - ) - ->each(actions: function (Amount $amount) use (&$totalDiscounted): void { - $totalDiscounted += $amount->value; - }); + ); + + /** @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); @@ -1040,4 +1039,169 @@ public function testChainedOperationsWithIntegers(): void /** @Then the sum should be 171700 */ self::assertSame(171700, $sum); } + + public function testFromClosure(): 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); + + /** @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 testFromClosureReiteratesSuccessfully(): void + { + /** @Given a closure that yields elements */ + $collection = Collection::createLazyFromClosure(factory: static function (): Generator { + yield 10; + yield 20; + yield 30; + }); + + /** @When 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([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 testFromClosureWithEmptyClosure(): 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 testFromClosureWithChainedOperations(): void + { + /** @Given a closure-backed collection with integers */ + $collection = Collection::createLazyFromClosure(factory: static function (): Generator { + yield from [5, 3, 1, 4, 2]; + }); + + /** @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 result should contain the filtered, mapped and sorted values */ + self::assertSame([30, 40, 50], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testFromClosureWithObjects(): 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); + }); + + /** @When reducing to sum all amounts */ + $total = $collection->reduce( + accumulator: static fn(float $carry, Amount $amount): float => $carry + $amount->value, + initial: 0.0 + ); + + /** @Then the total should be 600 */ + self::assertSame(600.00, $total); + } + + public function testFromClosureGetByIndex(): void + { + /** @Given a closure-backed collection */ + $collection = Collection::createLazyFromClosure(factory: static function (): Generator { + yield 'alpha'; + yield 'beta'; + yield 'gamma'; + }); + + /** @When retrieving element at index 1 */ + $actual = $collection->getBy(index: 1); + + /** @Then it should return the second element */ + self::assertSame('beta', $actual); + } + + public function testFromClosureContainsElement(): void + { + /** @Given a closure-backed collection */ + $collection = Collection::createLazyFromClosure(factory: static function (): Generator { + yield 'alpha'; + yield 'beta'; + yield 'gamma'; + }); + + /** @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')); + } + + public function testFromClosureAdd(): void + { + /** @Given a closure-backed collection */ + $collection = Collection::createLazyFromClosure(factory: static function (): Generator { + yield 1; + yield 2; + }); + + /** @When adding elements */ + $actual = $collection->add(3, 4); + + /** @Then all elements should be present */ + self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testFromClosureMerge(): void + { + /** @Given a closure-backed collection */ + $closureCollection = Collection::createLazyFromClosure(factory: static function (): Generator { + yield 1; + yield 2; + }); + + /** @And an eager collection */ + $eagerCollection = Collection::createFrom(elements: [3, 4]); + + /** @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)); + } }