From f8473ae3ffb0fd16bfaa040f6282aa3e213bf7e5 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 20 Oct 2025 09:45:23 +0200 Subject: [PATCH 1/2] Added tests for Checkbox list twig component --- .../Components/Checkbox/ListFieldTest.php | 172 ++++++++++++++++++ .../Twig/Components/ListFieldTraitTest.php | 63 +++++++ .../Twig/Stub/DummyListFieldComponent.php | 34 ++++ 3 files changed, 269 insertions(+) create mode 100644 tests/integration/Twig/Components/Checkbox/ListFieldTest.php create mode 100644 tests/integration/Twig/Components/ListFieldTraitTest.php create mode 100644 tests/integration/Twig/Stub/DummyListFieldComponent.php diff --git a/tests/integration/Twig/Components/Checkbox/ListFieldTest.php b/tests/integration/Twig/Components/Checkbox/ListFieldTest.php new file mode 100644 index 00000000..59e9bd95 --- /dev/null +++ b/tests/integration/Twig/Components/Checkbox/ListFieldTest.php @@ -0,0 +1,172 @@ +mountTwigComponent(ListField::class, $this->baseProps()); + self::assertInstanceOf(ListField::class, $component, 'Component should mount as Checkbox\\ListField.'); + } + + public function testDefaultRenderProducesWrapperAndRendersItems(): void + { + $crawler = $this->renderTwigComponent(ListField::class, $this->baseProps())->crawler(); + + $wrapper = $this->getWrapper($crawler); + $classes = $this->getClassAttr($wrapper); + self::assertStringContainsString('ids-field', $classes, 'Wrapper should include "ids-field".'); + self::assertStringContainsString('ids-field--list', $classes, 'Wrapper should include "ids-field--list".'); + self::assertStringContainsString('ids-choice-inputs-list', $classes, 'Wrapper should include "ids-choice-inputs-list".'); + self::assertStringContainsString('ids-checkboxes-list-field', $classes, 'Wrapper should include "ids-checkboxes-list-field".'); + + $items = $crawler->filter('.ids-choice-inputs-list__items .ids-checkbox-field'); + self::assertSame(2, $items->count(), 'Should render exactly two checkbox field items.'); + + $firstInput = $this->getCheckboxInput($items->eq(0)); + $secondInput = $this->getCheckboxInput($items->eq(1)); + + self::assertSame('checkbox', $firstInput->attr('type'), 'First item should be a checkbox input.'); + self::assertSame('group', $firstInput->attr('name'), 'First item "name" should be taken from the top-level group.'); + self::assertSame('checkbox', $secondInput->attr('type'), 'Second item should be a checkbox input.'); + self::assertSame('group', $secondInput->attr('name'), 'Second item "name" should be taken from the top-level group.'); + + self::assertStringContainsString('Pick A', $this->getText($items->eq(0)), 'First item should render its label.'); + self::assertStringContainsString('Pick B', $this->getText($items->eq(1)), 'Second item should render its label.'); + } + + public function testWrapperAttributesMergeClassAndData(): void + { + $crawler = $this->renderTwigComponent( + ListField::class, + $this->baseProps([ + 'attributes' => ['class' => 'extra-class', 'data-custom' => 'custom'], + ]) + )->crawler(); + + $wrapper = $this->getWrapper($crawler); + self::assertStringContainsString('extra-class', $this->getClassAttr($wrapper), 'Custom wrapper class should be merged.'); + self::assertSame('custom', $wrapper->attr('data-custom'), 'Custom data attribute should render on the wrapper.'); + } + + public function testDirectionVariantAddsExpectedClass(): void + { + $crawler = $this->renderTwigComponent( + ListField::class, + $this->baseProps(['direction' => 'horizontal']) + )->crawler(); + + $wrapper = $this->getWrapper($crawler); + + self::assertStringContainsString('ids-choice-inputs-list--horizontal', $this->getClassAttr($wrapper), 'Direction "horizontal" should add the corresponding class.'); + } + + public function testPerItemPropsAreForwardedToNestedField(): void + { + $props = $this->baseProps(); + $props['items'][0]['disabled'] = true; + $props['items'][1]['required'] = true; + + $crawler = $this->renderTwigComponent( + ListField::class, + $props + )->crawler(); + + $items = $crawler->filter('.ids-choice-inputs-list__items .ids-checkbox-field'); + $first = $this->getCheckboxInput($items->eq(0)); + $second = $this->getCheckboxInput($items->eq(1)); + + self::assertNotNull($first->attr('disabled'), 'Disabled=true on first item should render native "disabled".'); + self::assertNull($first->attr('required'), 'First item should not be required.'); + + self::assertNull($second->attr('disabled'), 'Second item should not be disabled.'); + self::assertNotNull($second->attr('required'), 'Required=true on second item should render native "required".'); + } + + public function testInvalidItemsTypeCausesResolverErrorOnMount(): void + { + $this->expectException(InvalidOptionsException::class); + + $this->mountTwigComponent(ListField::class, [ + 'name' => 'group', + 'items' => 'oops', + ]); + } + + public function testMissingRequiredOptionsCauseResolverErrorOnMount(): void + { + $this->expectException(MissingOptionsException::class); + + $this->mountTwigComponent(ListField::class, [ + 'items' => [ + ['id' => 'opt-a', 'label' => 'Pick A'], + ], + ]); + } + + /** + * @param array $overrides + * + * @return array + */ + private function baseProps(array $overrides = []): array + { + return array_replace([ + 'name' => 'group', + 'items' => [ + [ + 'id' => 'opt-a', + 'label' => 'Pick A', + 'value' => 'A', + ], + [ + 'id' => 'opt-b', + 'label' => 'Pick B', + 'value' => 'B', + ], + ], + ], $overrides); + } + + private function getWrapper(Crawler $crawler): Crawler + { + $node = $crawler->filter('.ids-field')->first(); + self::assertGreaterThan(0, $node->count(), 'Wrapper ".ids-field" should be present.'); + + return $node; + } + + private function getCheckboxInput(Crawler $scope): Crawler + { + $node = $scope->filter('input[type="checkbox"]')->first(); + self::assertGreaterThan(0, $node->count(), 'Checkbox input should be present.'); + + return $node; + } + + private function getClassAttr(Crawler $node): string + { + return (string) $node->attr('class'); + } + + private function getText(Crawler $node): string + { + return trim($node->text('')); + } +} diff --git a/tests/integration/Twig/Components/ListFieldTraitTest.php b/tests/integration/Twig/Components/ListFieldTraitTest.php new file mode 100644 index 00000000..6dd4ea01 --- /dev/null +++ b/tests/integration/Twig/Components/ListFieldTraitTest.php @@ -0,0 +1,63 @@ +getComponent()->resolve([ + 'items' => [ + ['id' => 'a', 'label' => 'Alpha', 'value' => 'A'], + ['id' => 'b', 'label' => 'Beta', 'value' => 'B'], + ], + 'direction' => 'horizontal', + ]); + + self::assertArrayHasKey('items', $resolved, 'Resolved options should contain "items".'); + self::assertCount(2, $resolved['items'], '"items" should contain two entries.'); + self::assertSame('horizontal', $resolved['direction'] ?? null, '"direction" should resolve to HORIZONTAL.'); + } + + public function testDefaultsWhenNoOptionsProvided(): void + { + $resolved = $this->getComponent()->resolve([]); + + self::assertSame([], $resolved['items'], '"items" should default to an empty array.'); + self::assertSame('vertical', $resolved['direction'], '"direction" should default to VERTICAL.'); + } + + public function testInvalidItemsTypeCausesResolverError(): void + { + $this->expectException(InvalidOptionsException::class); + + $this->getComponent()->resolve([ + 'items' => 'not-an-array', + ]); + } + + public function testInvalidDirectionValueCausesResolverError(): void + { + $this->expectException(InvalidOptionsException::class); + + $this->getComponent()->resolve([ + 'items' => [['id' => 'a', 'label' => 'Alpha', 'value' => 'A']], + 'direction' => 'diagonal', + ]); + } + + private function getComponent(): DummyListFieldComponent + { + return new DummyListFieldComponent(); + } +} diff --git a/tests/integration/Twig/Stub/DummyListFieldComponent.php b/tests/integration/Twig/Stub/DummyListFieldComponent.php new file mode 100644 index 00000000..5181b709 --- /dev/null +++ b/tests/integration/Twig/Stub/DummyListFieldComponent.php @@ -0,0 +1,34 @@ + $options + * + * @return array + */ + public function resolve(array $options): array + { + $resolver = new OptionsResolver(); + $this->validateListFieldProps($resolver); + + return $resolver->resolve($options); + } +} From bab94c31cbeb10688e1300bb3a62d0e103f229e0 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 27 Oct 2025 10:38:25 +0100 Subject: [PATCH 2/2] Refactor assertions in ListFieldTest and ListFieldTraitTest for improved readability --- .../Components/Checkbox/ListFieldTest.php | 126 ++++++++++++++---- .../Twig/Components/ListFieldTraitTest.php | 30 ++++- 2 files changed, 128 insertions(+), 28 deletions(-) diff --git a/tests/integration/Twig/Components/Checkbox/ListFieldTest.php b/tests/integration/Twig/Components/Checkbox/ListFieldTest.php index 59e9bd95..ca93c822 100644 --- a/tests/integration/Twig/Components/Checkbox/ListFieldTest.php +++ b/tests/integration/Twig/Components/Checkbox/ListFieldTest.php @@ -22,7 +22,11 @@ final class ListFieldTest extends KernelTestCase public function testMount(): void { $component = $this->mountTwigComponent(ListField::class, $this->baseProps()); - self::assertInstanceOf(ListField::class, $component, 'Component should mount as Checkbox\\ListField.'); + self::assertInstanceOf( + ListField::class, + $component, + 'Component should mount as Checkbox\\ListField.' + ); } public function testDefaultRenderProducesWrapperAndRendersItems(): void @@ -31,24 +35,68 @@ public function testDefaultRenderProducesWrapperAndRendersItems(): void $wrapper = $this->getWrapper($crawler); $classes = $this->getClassAttr($wrapper); - self::assertStringContainsString('ids-field', $classes, 'Wrapper should include "ids-field".'); - self::assertStringContainsString('ids-field--list', $classes, 'Wrapper should include "ids-field--list".'); - self::assertStringContainsString('ids-choice-inputs-list', $classes, 'Wrapper should include "ids-choice-inputs-list".'); - self::assertStringContainsString('ids-checkboxes-list-field', $classes, 'Wrapper should include "ids-checkboxes-list-field".'); + self::assertStringContainsString( + 'ids-field', + $classes, + 'Wrapper should include "ids-field".' + ); + self::assertStringContainsString( + 'ids-field--list', + $classes, + 'Wrapper should include "ids-field--list".' + ); + self::assertStringContainsString( + 'ids-choice-inputs-list', + $classes, + 'Wrapper should include "ids-choice-inputs-list".' + ); + self::assertStringContainsString( + 'ids-checkboxes-list-field', + $classes, + 'Wrapper should include "ids-checkboxes-list-field".' + ); $items = $crawler->filter('.ids-choice-inputs-list__items .ids-checkbox-field'); - self::assertSame(2, $items->count(), 'Should render exactly two checkbox field items.'); + self::assertSame( + 2, + $items->count(), + 'Should render exactly two checkbox field items.' + ); $firstInput = $this->getCheckboxInput($items->eq(0)); $secondInput = $this->getCheckboxInput($items->eq(1)); - self::assertSame('checkbox', $firstInput->attr('type'), 'First item should be a checkbox input.'); - self::assertSame('group', $firstInput->attr('name'), 'First item "name" should be taken from the top-level group.'); - self::assertSame('checkbox', $secondInput->attr('type'), 'Second item should be a checkbox input.'); - self::assertSame('group', $secondInput->attr('name'), 'Second item "name" should be taken from the top-level group.'); - - self::assertStringContainsString('Pick A', $this->getText($items->eq(0)), 'First item should render its label.'); - self::assertStringContainsString('Pick B', $this->getText($items->eq(1)), 'Second item should render its label.'); + self::assertSame( + 'checkbox', + $firstInput->attr('type'), + 'First item should be a checkbox input.' + ); + self::assertSame( + 'group', + $firstInput->attr('name'), + 'First item "name" should be taken from the top-level group.' + ); + self::assertSame( + 'checkbox', + $secondInput->attr('type'), + 'Second item should be a checkbox input.' + ); + self::assertSame( + 'group', + $secondInput->attr('name'), + 'Second item "name" should be taken from the top-level group.' + ); + + self::assertStringContainsString( + 'Pick A', + $this->getText($items->eq(0)), + 'First item should render its label.' + ); + self::assertStringContainsString( + 'Pick B', + $this->getText($items->eq(1)), + 'Second item should render its label.' + ); } public function testWrapperAttributesMergeClassAndData(): void @@ -61,8 +109,16 @@ public function testWrapperAttributesMergeClassAndData(): void )->crawler(); $wrapper = $this->getWrapper($crawler); - self::assertStringContainsString('extra-class', $this->getClassAttr($wrapper), 'Custom wrapper class should be merged.'); - self::assertSame('custom', $wrapper->attr('data-custom'), 'Custom data attribute should render on the wrapper.'); + self::assertStringContainsString( + 'extra-class', + $this->getClassAttr($wrapper), + 'Custom wrapper class should be merged.' + ); + self::assertSame( + 'custom', + $wrapper->attr('data-custom'), + 'Custom data attribute should render on the wrapper.' + ); } public function testDirectionVariantAddsExpectedClass(): void @@ -74,7 +130,11 @@ public function testDirectionVariantAddsExpectedClass(): void $wrapper = $this->getWrapper($crawler); - self::assertStringContainsString('ids-choice-inputs-list--horizontal', $this->getClassAttr($wrapper), 'Direction "horizontal" should add the corresponding class.'); + self::assertStringContainsString( + 'ids-choice-inputs-list--horizontal', + $this->getClassAttr($wrapper), + 'Direction "horizontal" should add the corresponding class.' + ); } public function testPerItemPropsAreForwardedToNestedField(): void @@ -92,11 +152,23 @@ public function testPerItemPropsAreForwardedToNestedField(): void $first = $this->getCheckboxInput($items->eq(0)); $second = $this->getCheckboxInput($items->eq(1)); - self::assertNotNull($first->attr('disabled'), 'Disabled=true on first item should render native "disabled".'); - self::assertNull($first->attr('required'), 'First item should not be required.'); - - self::assertNull($second->attr('disabled'), 'Second item should not be disabled.'); - self::assertNotNull($second->attr('required'), 'Required=true on second item should render native "required".'); + self::assertNotNull( + $first->attr('disabled'), + 'Disabled=true on first item should render native "disabled".' + ); + self::assertNull( + $first->attr('required'), + 'First item should not be required.' + ); + + self::assertNull( + $second->attr('disabled'), + 'Second item should not be disabled.' + ); + self::assertNotNull( + $second->attr('required'), + 'Required=true on second item should render native "required".' + ); } public function testInvalidItemsTypeCausesResolverErrorOnMount(): void @@ -147,7 +219,11 @@ private function baseProps(array $overrides = []): array private function getWrapper(Crawler $crawler): Crawler { $node = $crawler->filter('.ids-field')->first(); - self::assertGreaterThan(0, $node->count(), 'Wrapper ".ids-field" should be present.'); + self::assertGreaterThan( + 0, + $node->count(), + 'Wrapper ".ids-field" should be present.' + ); return $node; } @@ -155,7 +231,11 @@ private function getWrapper(Crawler $crawler): Crawler private function getCheckboxInput(Crawler $scope): Crawler { $node = $scope->filter('input[type="checkbox"]')->first(); - self::assertGreaterThan(0, $node->count(), 'Checkbox input should be present.'); + self::assertGreaterThan( + 0, + $node->count(), + 'Checkbox input should be present.' + ); return $node; } diff --git a/tests/integration/Twig/Components/ListFieldTraitTest.php b/tests/integration/Twig/Components/ListFieldTraitTest.php index 6dd4ea01..6998bbb3 100644 --- a/tests/integration/Twig/Components/ListFieldTraitTest.php +++ b/tests/integration/Twig/Components/ListFieldTraitTest.php @@ -24,17 +24,37 @@ public function testResolveWithValidItemsAndHorizontalDirectionSucceeds(): void 'direction' => 'horizontal', ]); - self::assertArrayHasKey('items', $resolved, 'Resolved options should contain "items".'); - self::assertCount(2, $resolved['items'], '"items" should contain two entries.'); - self::assertSame('horizontal', $resolved['direction'] ?? null, '"direction" should resolve to HORIZONTAL.'); + self::assertArrayHasKey( + 'items', + $resolved, + 'Resolved options should contain "items".' + ); + self::assertCount( + 2, + $resolved['items'], + '"items" should contain two entries.' + ); + self::assertSame( + 'horizontal', + $resolved['direction'] ?? null, + '"direction" should resolve to HORIZONTAL.' + ); } public function testDefaultsWhenNoOptionsProvided(): void { $resolved = $this->getComponent()->resolve([]); - self::assertSame([], $resolved['items'], '"items" should default to an empty array.'); - self::assertSame('vertical', $resolved['direction'], '"direction" should default to VERTICAL.'); + self::assertSame( + [], + $resolved['items'], + '"items" should default to an empty array.' + ); + self::assertSame( + 'vertical', + $resolved['direction'], + '"direction" should default to VERTICAL.' + ); } public function testInvalidItemsTypeCausesResolverError(): void