From 80fdba1dac46704cfdb86ec29adc9d72fd897d84 Mon Sep 17 00:00:00 2001 From: Joakim Friberg Date: Wed, 14 Jan 2026 12:45:44 +0100 Subject: [PATCH 1/2] Fix: Merge type selections for inline fragments with shared field names When multiple inline fragments selected the same field name with different concrete return types, only the first type class was generated. This caused missing class errors for subsequent fragments. The bug was in OperationStack::setSelection() using `??=` which ignored subsequent selections for the same namespace. Changed to conditionally merge new types while preserving existing ones to avoid reprocessing subtrees. --- src/Codegen/OperationStack.php | 15 +++- tests/Unit/Codegen/OperationStackTest.php | 95 +++++++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Codegen/OperationStackTest.php diff --git a/src/Codegen/OperationStack.php b/src/Codegen/OperationStack.php index 3bd2e4fc..ca738c07 100644 --- a/src/Codegen/OperationStack.php +++ b/src/Codegen/OperationStack.php @@ -23,8 +23,19 @@ public function __construct(OperationBuilder $operation) /** @param array $selection */ public function setSelection(string $namespace, array $selection): void { - // Ignore if already set, we already were in that subtree - $this->selections[$namespace] ??= $selection; + // Merge with existing selection to handle multiple inline fragments + // with the same field name but different types + if (!isset($this->selections[$namespace])) { + $this->selections[$namespace] = $selection; + } else { + // Only add new types, don't overwrite existing ones + // (we already processed that type's subtree) + foreach ($selection as $typeName => $builder) { + if (!isset($this->selections[$namespace][$typeName])) { + $this->selections[$namespace][$typeName] = $builder; + } + } + } } /** @return iterable */ diff --git a/tests/Unit/Codegen/OperationStackTest.php b/tests/Unit/Codegen/OperationStackTest.php new file mode 100644 index 00000000..548ca69a --- /dev/null +++ b/tests/Unit/Codegen/OperationStackTest.php @@ -0,0 +1,95 @@ +createMock(OperationBuilder::class); + $stack = new OperationStack($operation); + + $namespace = 'App\\Generated\\GetCompanyUpdates\\Changes\\Article'; + + // Simulate processing first inline fragment (NotePublishedCompanyUpdateChange) + $noteBuilder = $this->createMock(ObjectLikeBuilder::class); + $stack->setSelection($namespace, ['Note' => $noteBuilder]); + + // Simulate processing second inline fragment (ResearchUpdatePublishedCompanyUpdateChange) + $updateBuilder = $this->createMock(ObjectLikeBuilder::class); + $stack->setSelection($namespace, ['Update' => $updateBuilder]); + + // Simulate processing third inline fragment (InitiationCoveragePublishedCompanyUpdateChange) + $initiationCoverageBuilder = $this->createMock(ObjectLikeBuilder::class); + $stack->setSelection($namespace, ['InitiationCoverage' => $initiationCoverageBuilder]); + + // Get the merged selections + $selections = $stack->selection($namespace); + + // Assert all three types are present (this fails with the bug) + self::assertArrayHasKey('Note', $selections, 'Note type should be present'); + self::assertArrayHasKey('Update', $selections, 'Update type should be present'); + self::assertArrayHasKey('InitiationCoverage', $selections, 'InitiationCoverage type should be present'); + + // Verify we have exactly 3 types + self::assertCount(3, $selections, 'Should have all 3 article types'); + + // Verify the builders are the correct instances + self::assertSame($noteBuilder, $selections['Note']); + self::assertSame($updateBuilder, $selections['Update']); + self::assertSame($initiationCoverageBuilder, $selections['InitiationCoverage']); + } + + public function testSetSelectionDoesNotDuplicateTypesWhenCalledMultipleTimes(): void + { + $operation = $this->createMock(OperationBuilder::class); + $stack = new OperationStack($operation); + + $namespace = 'App\\Generated\\GetCompanyUpdates\\Changes\\Article'; + + $noteBuilder = $this->createMock(ObjectLikeBuilder::class); + + // Set the same type multiple times + $stack->setSelection($namespace, ['Note' => $noteBuilder]); + $stack->setSelection($namespace, ['Note' => $noteBuilder]); + + $selections = $stack->selection($namespace); + + // Should still only have one Note entry + self::assertCount(1, $selections); + self::assertArrayHasKey('Note', $selections); + } + + public function testSetSelectionHandlesDifferentNamespacesSeparately(): void + { + $operation = $this->createMock(OperationBuilder::class); + $stack = new OperationStack($operation); + + $namespace1 = 'App\\Generated\\Query1\\Article'; + $namespace2 = 'App\\Generated\\Query2\\Article'; + + $noteBuilder1 = $this->createMock(ObjectLikeBuilder::class); + $noteBuilder2 = $this->createMock(ObjectLikeBuilder::class); + + $stack->setSelection($namespace1, ['Note' => $noteBuilder1]); + $stack->setSelection($namespace2, ['Note' => $noteBuilder2]); + + $selections1 = $stack->selection($namespace1); + $selections2 = $stack->selection($namespace2); + + // Each namespace should have its own selection + self::assertArrayHasKey('Note', $selections1); + self::assertArrayHasKey('Note', $selections2); + self::assertSame($noteBuilder1, $selections1['Note']); + self::assertSame($noteBuilder2, $selections2['Note']); + } +} From 4f0883932db3719d68242dcfa9fa94952f3c6e0e Mon Sep 17 00:00:00 2001 From: Joakim Friberg Date: Mon, 19 Jan 2026 20:47:49 +0100 Subject: [PATCH 2/2] Replace unit test with snapshot test example Added examples/inline-fragments/ to demonstrate the bug fix. The example has two inline fragments selecting a 'content' field with different return types (ArticleContent vs VideoContent). Without the fix, only the first type class gets generated. - Add examples/inline-fragments/ with schema and query - Update tests/Examples.php and Makefile - Remove tests/Unit/Codegen/OperationStackTest.php --- Makefile | 1 + examples/inline-fragments/.gitignore | 4 + examples/inline-fragments/composer.json | 24 +++++ .../expected/Operations/SearchQuery.php | 65 +++++++++++++ .../Operations/SearchQuery/Search/Article.php | 58 +++++++++++ .../Search/Content/ArticleContent.php | 46 +++++++++ .../Search/Content/VideoContent.php | 52 ++++++++++ .../Operations/SearchQuery/Search/Video.php | 58 +++++++++++ .../Operations/SearchQuery/SearchQuery.php | 49 ++++++++++ .../SearchQueryErrorFreeResult.php | 18 ++++ .../SearchQuery/SearchQueryResult.php | 41 ++++++++ .../expected/Types/__DirectiveLocation.php | 36 +++++++ .../expected/Types/__TypeKind.php | 25 +++++ examples/inline-fragments/sailor.php | 62 ++++++++++++ examples/inline-fragments/schema.graphql | 28 ++++++ .../inline-fragments/src/SearchQuery.graphql | 18 ++++ examples/inline-fragments/src/test.php | 20 ++++ examples/inline-fragments/test.sh | 8 ++ tests/Examples.php | 1 + tests/Unit/Codegen/OperationStackTest.php | 95 ------------------- 20 files changed, 614 insertions(+), 95 deletions(-) create mode 100644 examples/inline-fragments/.gitignore create mode 100644 examples/inline-fragments/composer.json create mode 100644 examples/inline-fragments/expected/Operations/SearchQuery.php create mode 100644 examples/inline-fragments/expected/Operations/SearchQuery/Search/Article.php create mode 100644 examples/inline-fragments/expected/Operations/SearchQuery/Search/Content/ArticleContent.php create mode 100644 examples/inline-fragments/expected/Operations/SearchQuery/Search/Content/VideoContent.php create mode 100644 examples/inline-fragments/expected/Operations/SearchQuery/Search/Video.php create mode 100644 examples/inline-fragments/expected/Operations/SearchQuery/SearchQuery.php create mode 100644 examples/inline-fragments/expected/Operations/SearchQuery/SearchQueryErrorFreeResult.php create mode 100644 examples/inline-fragments/expected/Operations/SearchQuery/SearchQueryResult.php create mode 100644 examples/inline-fragments/expected/Types/__DirectiveLocation.php create mode 100644 examples/inline-fragments/expected/Types/__TypeKind.php create mode 100644 examples/inline-fragments/sailor.php create mode 100644 examples/inline-fragments/schema.graphql create mode 100644 examples/inline-fragments/src/SearchQuery.graphql create mode 100644 examples/inline-fragments/src/test.php create mode 100755 examples/inline-fragments/test.sh delete mode 100644 tests/Unit/Codegen/OperationStackTest.php diff --git a/Makefile b/Makefile index bc88d408..126fb896 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,7 @@ approve: ## Generate code and approve it as expected .PHONY: test-examples test-examples: ## Test examples cd examples/custom-types && ./test.sh + cd examples/inline-fragments && ./test.sh cd examples/input && ./test.sh cd examples/install && ./test.sh cd examples/php-keywords && ./test.sh diff --git a/examples/inline-fragments/.gitignore b/examples/inline-fragments/.gitignore new file mode 100644 index 00000000..03516443 --- /dev/null +++ b/examples/inline-fragments/.gitignore @@ -0,0 +1,4 @@ +/sailor +/vendor +/composer.lock +/generated diff --git a/examples/inline-fragments/composer.json b/examples/inline-fragments/composer.json new file mode 100644 index 00000000..23cefe8c --- /dev/null +++ b/examples/inline-fragments/composer.json @@ -0,0 +1,24 @@ +{ + "require": { + "spawnia/sailor": "@dev" + }, + "autoload": { + "psr-4": { + "Spawnia\\Sailor\\InlineFragments\\": "generated/" + } + }, + "repositories": [ + { + "type": "path", + "url": "./sailor", + "options": { + "symlink": false + } + } + ], + "scripts": { + "move-package": "rsync --recursive ../../ sailor --exclude examples --exclude vendor --exclude .idea --exclude .git --exclude .build --delete", + "pre-install-cmd": "@move-package", + "pre-update-cmd": "@move-package" + } +} diff --git a/examples/inline-fragments/expected/Operations/SearchQuery.php b/examples/inline-fragments/expected/Operations/SearchQuery.php new file mode 100644 index 00000000..1971cb3e --- /dev/null +++ b/examples/inline-fragments/expected/Operations/SearchQuery.php @@ -0,0 +1,65 @@ + + */ +class SearchQuery extends \Spawnia\Sailor\Operation +{ + /** + * @param string $query + */ + public static function execute($query): SearchQuery\SearchQueryResult + { + return self::executeOperation( + $query, + ); + } + + protected static function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + ['query', new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter)], + ]; + } + + public static function document(): string + { + return /* @lang GraphQL */ 'query SearchQuery($query: String!) { + __typename + search(query: $query) { + __typename + id + ... on Article { + title + content { + __typename + text + } + } + ... on Video { + title + content { + __typename + url + duration + } + } + } + }'; + } + + public static function endpoint(): string + { + return 'inline-fragments'; + } + + public static function config(): string + { + return \Safe\realpath(__DIR__ . '/../../sailor.php'); + } +} diff --git a/examples/inline-fragments/expected/Operations/SearchQuery/Search/Article.php b/examples/inline-fragments/expected/Operations/SearchQuery/Search/Article.php new file mode 100644 index 00000000..a0aa7773 --- /dev/null +++ b/examples/inline-fragments/expected/Operations/SearchQuery/Search/Article.php @@ -0,0 +1,58 @@ +__set('id', $id); + } + if ($title !== self::UNDEFINED) { + $instance->__set('title', $title); + } + if ($content !== self::UNDEFINED) { + $instance->__set('content', $content); + } + $instance->__typename = 'Article'; + + return $instance; + } + + protected function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + 'id' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\IDConverter), + 'title' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + 'content' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\InlineFragments\Operations\SearchQuery\Search\Content\ArticleContent), + '__typename' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + ]; + } + + public static function endpoint(): string + { + return 'inline-fragments'; + } + + public static function config(): string + { + return \Safe\realpath(__DIR__ . '/../../../../sailor.php'); + } +} diff --git a/examples/inline-fragments/expected/Operations/SearchQuery/Search/Content/ArticleContent.php b/examples/inline-fragments/expected/Operations/SearchQuery/Search/Content/ArticleContent.php new file mode 100644 index 00000000..a01910c7 --- /dev/null +++ b/examples/inline-fragments/expected/Operations/SearchQuery/Search/Content/ArticleContent.php @@ -0,0 +1,46 @@ +__set('text', $text); + } + $instance->__typename = 'ArticleContent'; + + return $instance; + } + + protected function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + 'text' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + '__typename' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + ]; + } + + public static function endpoint(): string + { + return 'inline-fragments'; + } + + public static function config(): string + { + return \Safe\realpath(__DIR__ . '/../../../../../sailor.php'); + } +} diff --git a/examples/inline-fragments/expected/Operations/SearchQuery/Search/Content/VideoContent.php b/examples/inline-fragments/expected/Operations/SearchQuery/Search/Content/VideoContent.php new file mode 100644 index 00000000..5221f663 --- /dev/null +++ b/examples/inline-fragments/expected/Operations/SearchQuery/Search/Content/VideoContent.php @@ -0,0 +1,52 @@ +__set('url', $url); + } + if ($duration !== self::UNDEFINED) { + $instance->__set('duration', $duration); + } + $instance->__typename = 'VideoContent'; + + return $instance; + } + + protected function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + 'url' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + 'duration' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\IntConverter), + '__typename' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + ]; + } + + public static function endpoint(): string + { + return 'inline-fragments'; + } + + public static function config(): string + { + return \Safe\realpath(__DIR__ . '/../../../../../sailor.php'); + } +} diff --git a/examples/inline-fragments/expected/Operations/SearchQuery/Search/Video.php b/examples/inline-fragments/expected/Operations/SearchQuery/Search/Video.php new file mode 100644 index 00000000..89122f50 --- /dev/null +++ b/examples/inline-fragments/expected/Operations/SearchQuery/Search/Video.php @@ -0,0 +1,58 @@ +__set('id', $id); + } + if ($title !== self::UNDEFINED) { + $instance->__set('title', $title); + } + if ($content !== self::UNDEFINED) { + $instance->__set('content', $content); + } + $instance->__typename = 'Video'; + + return $instance; + } + + protected function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + 'id' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\IDConverter), + 'title' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + 'content' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\InlineFragments\Operations\SearchQuery\Search\Content\VideoContent), + '__typename' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + ]; + } + + public static function endpoint(): string + { + return 'inline-fragments'; + } + + public static function config(): string + { + return \Safe\realpath(__DIR__ . '/../../../../sailor.php'); + } +} diff --git a/examples/inline-fragments/expected/Operations/SearchQuery/SearchQuery.php b/examples/inline-fragments/expected/Operations/SearchQuery/SearchQuery.php new file mode 100644 index 00000000..a1ec53db --- /dev/null +++ b/examples/inline-fragments/expected/Operations/SearchQuery/SearchQuery.php @@ -0,0 +1,49 @@ + $search + * @property string $__typename + */ +class SearchQuery extends \Spawnia\Sailor\ObjectLike +{ + /** + * @param array $search + */ + public static function make($search): self + { + $instance = new self; + + if ($search !== self::UNDEFINED) { + $instance->__set('search', $search); + } + $instance->__typename = 'Query'; + + return $instance; + } + + protected function converters(): array + { + /** @var array|null $converters */ + static $converters; + + return $converters ??= [ + 'search' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\ListConverter(new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\PolymorphicConverter([ + 'Article' => '\\Spawnia\\Sailor\\InlineFragments\\Operations\\SearchQuery\\Search\\Article', + 'Video' => '\\Spawnia\\Sailor\\InlineFragments\\Operations\\SearchQuery\\Search\\Video', + ])))), + '__typename' => new \Spawnia\Sailor\Convert\NonNullConverter(new \Spawnia\Sailor\Convert\StringConverter), + ]; + } + + public static function endpoint(): string + { + return 'inline-fragments'; + } + + public static function config(): string + { + return \Safe\realpath(__DIR__ . '/../../../sailor.php'); + } +} diff --git a/examples/inline-fragments/expected/Operations/SearchQuery/SearchQueryErrorFreeResult.php b/examples/inline-fragments/expected/Operations/SearchQuery/SearchQueryErrorFreeResult.php new file mode 100644 index 00000000..30df8965 --- /dev/null +++ b/examples/inline-fragments/expected/Operations/SearchQuery/SearchQueryErrorFreeResult.php @@ -0,0 +1,18 @@ +data = SearchQuery::fromStdClass($data); + } + + /** + * Useful for instantiation of successful mocked results. + * + * @return static + */ + public static function fromData(SearchQuery $data): self + { + $instance = new static; + $instance->data = $data; + + return $instance; + } + + public function errorFree(): SearchQueryErrorFreeResult + { + return SearchQueryErrorFreeResult::fromResult($this); + } + + public static function endpoint(): string + { + return 'inline-fragments'; + } + + public static function config(): string + { + return \Safe\realpath(__DIR__ . '/../../../sailor.php'); + } +} diff --git a/examples/inline-fragments/expected/Types/__DirectiveLocation.php b/examples/inline-fragments/expected/Types/__DirectiveLocation.php new file mode 100644 index 00000000..7760888b --- /dev/null +++ b/examples/inline-fragments/expected/Types/__DirectiveLocation.php @@ -0,0 +1,36 @@ + new class() extends EndpointConfig { + public function namespace(): string + { + return 'Spawnia\Sailor\InlineFragments'; + } + + public function targetPath(): string + { + return __DIR__ . '/generated'; + } + + public function schemaPath(): string + { + return __DIR__ . '/schema.graphql'; + } + + public function finder(): Finder + { + return new DirectoryFinder(__DIR__ . '/src'); + } + + public function makeClient(): Client + { + return new MockClient(static fn(): Response => Response::fromStdClass((object) [ + 'data' => (object) [ + '__typename' => 'Query', + 'search' => [ + (object) [ + '__typename' => 'Article', + 'id' => '1', + 'title' => 'Test Article', + 'content' => (object) [ + '__typename' => 'ArticleContent', + 'text' => 'Article text', + ], + ], + (object) [ + '__typename' => 'Video', + 'id' => '2', + 'title' => 'Test Video', + 'content' => (object) [ + '__typename' => 'VideoContent', + 'url' => 'https://example.com/video.mp4', + 'duration' => 120, + ], + ], + ], + ], + ])); + } + }, +]; diff --git a/examples/inline-fragments/schema.graphql b/examples/inline-fragments/schema.graphql new file mode 100644 index 00000000..b4c0ebad --- /dev/null +++ b/examples/inline-fragments/schema.graphql @@ -0,0 +1,28 @@ +type Query { + search(query: String!): [SearchResult!]! +} + +interface SearchResult { + id: ID! +} + +type Article implements SearchResult { + id: ID! + title: String! + content: ArticleContent! +} + +type Video implements SearchResult { + id: ID! + title: String! + content: VideoContent! +} + +type ArticleContent { + text: String! +} + +type VideoContent { + url: String! + duration: Int! +} diff --git a/examples/inline-fragments/src/SearchQuery.graphql b/examples/inline-fragments/src/SearchQuery.graphql new file mode 100644 index 00000000..8f68c860 --- /dev/null +++ b/examples/inline-fragments/src/SearchQuery.graphql @@ -0,0 +1,18 @@ +query SearchQuery($query: String!) { + search(query: $query) { + id + ... on Article { + title + content { + text + } + } + ... on Video { + title + content { + url + duration + } + } + } +} diff --git a/examples/inline-fragments/src/test.php b/examples/inline-fragments/src/test.php new file mode 100644 index 00000000..78f2d1c9 --- /dev/null +++ b/examples/inline-fragments/src/test.php @@ -0,0 +1,20 @@ +errorFree(); + +$article = $result->data->search[0]; +assert($article instanceof SearchQuery\Search\Article); +assert($article->id === '1'); +assert($article->title === 'Test Article'); +assert($article->content->text === 'Article text'); + +$video = $result->data->search[1]; +assert($video instanceof SearchQuery\Search\Video); +assert($video->id === '2'); +assert($video->title === 'Test Video'); +assert($video->content->url === 'https://example.com/video.mp4'); +assert($video->content->duration === 120); diff --git a/examples/inline-fragments/test.sh b/examples/inline-fragments/test.sh new file mode 100755 index 00000000..6d8d226e --- /dev/null +++ b/examples/inline-fragments/test.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euxo pipefail + +composer update +composer reinstall spawnia/sailor +vendor/bin/sailor + +php src/test.php diff --git a/tests/Examples.php b/tests/Examples.php index 89a31019..55195509 100644 --- a/tests/Examples.php +++ b/tests/Examples.php @@ -13,6 +13,7 @@ final class Examples public const EXAMPLES = [ 'custom-types', + 'inline-fragments', 'input', 'php-keywords', 'polymorphic', diff --git a/tests/Unit/Codegen/OperationStackTest.php b/tests/Unit/Codegen/OperationStackTest.php deleted file mode 100644 index 548ca69a..00000000 --- a/tests/Unit/Codegen/OperationStackTest.php +++ /dev/null @@ -1,95 +0,0 @@ -createMock(OperationBuilder::class); - $stack = new OperationStack($operation); - - $namespace = 'App\\Generated\\GetCompanyUpdates\\Changes\\Article'; - - // Simulate processing first inline fragment (NotePublishedCompanyUpdateChange) - $noteBuilder = $this->createMock(ObjectLikeBuilder::class); - $stack->setSelection($namespace, ['Note' => $noteBuilder]); - - // Simulate processing second inline fragment (ResearchUpdatePublishedCompanyUpdateChange) - $updateBuilder = $this->createMock(ObjectLikeBuilder::class); - $stack->setSelection($namespace, ['Update' => $updateBuilder]); - - // Simulate processing third inline fragment (InitiationCoveragePublishedCompanyUpdateChange) - $initiationCoverageBuilder = $this->createMock(ObjectLikeBuilder::class); - $stack->setSelection($namespace, ['InitiationCoverage' => $initiationCoverageBuilder]); - - // Get the merged selections - $selections = $stack->selection($namespace); - - // Assert all three types are present (this fails with the bug) - self::assertArrayHasKey('Note', $selections, 'Note type should be present'); - self::assertArrayHasKey('Update', $selections, 'Update type should be present'); - self::assertArrayHasKey('InitiationCoverage', $selections, 'InitiationCoverage type should be present'); - - // Verify we have exactly 3 types - self::assertCount(3, $selections, 'Should have all 3 article types'); - - // Verify the builders are the correct instances - self::assertSame($noteBuilder, $selections['Note']); - self::assertSame($updateBuilder, $selections['Update']); - self::assertSame($initiationCoverageBuilder, $selections['InitiationCoverage']); - } - - public function testSetSelectionDoesNotDuplicateTypesWhenCalledMultipleTimes(): void - { - $operation = $this->createMock(OperationBuilder::class); - $stack = new OperationStack($operation); - - $namespace = 'App\\Generated\\GetCompanyUpdates\\Changes\\Article'; - - $noteBuilder = $this->createMock(ObjectLikeBuilder::class); - - // Set the same type multiple times - $stack->setSelection($namespace, ['Note' => $noteBuilder]); - $stack->setSelection($namespace, ['Note' => $noteBuilder]); - - $selections = $stack->selection($namespace); - - // Should still only have one Note entry - self::assertCount(1, $selections); - self::assertArrayHasKey('Note', $selections); - } - - public function testSetSelectionHandlesDifferentNamespacesSeparately(): void - { - $operation = $this->createMock(OperationBuilder::class); - $stack = new OperationStack($operation); - - $namespace1 = 'App\\Generated\\Query1\\Article'; - $namespace2 = 'App\\Generated\\Query2\\Article'; - - $noteBuilder1 = $this->createMock(ObjectLikeBuilder::class); - $noteBuilder2 = $this->createMock(ObjectLikeBuilder::class); - - $stack->setSelection($namespace1, ['Note' => $noteBuilder1]); - $stack->setSelection($namespace2, ['Note' => $noteBuilder2]); - - $selections1 = $stack->selection($namespace1); - $selections2 = $stack->selection($namespace2); - - // Each namespace should have its own selection - self::assertArrayHasKey('Note', $selections1); - self::assertArrayHasKey('Note', $selections2); - self::assertSame($noteBuilder1, $selections1['Note']); - self::assertSame($noteBuilder2, $selections2['Note']); - } -}