Skip to content

Commit c18a348

Browse files
committed
Add form tests + document the TypeTestCase + LiveCollection pattern
Three new test files covering src/Form: - tests/Form/Type/PriceTierTypeTest — TypeTestCase-based, 8 tests: bind quantity/discount/channel into a PriceTier; empty payload is a no-op (the nullable-setter contract); PRE_SET_DATA listener adds productVariant only when initial data has a product; data_class and validation_groups propagate from AbstractResourceType; block_prefix is stable. - tests/Form/Type/PriceTierCollectionTypeTest — TypeTestCase-based, 12 tests covering configureOptions() defaults, getParent(), getBlockPrefix(), AND the Live Component decoration upstream symfony/ux-live-component's own LiveCollectionTypeTest verifies: button_add has the live_collection_button_add block prefix and the data-action="live#action" + data-live-action-param="addCollectionItem" attrs; each entry's button_delete has the live_collection_button_delete block prefix. Plus a data-binding round-trip test: submitting an array of payloads produces one PriceTier per entry. This is the real contract test — without asserting on the decorated view, a regression that swaps the parent back to plain CollectionType would slip through. - tests/Form/Extension/ProductTypeExtensionTest — pure unit test with a prophesized FormBuilderInterface: extends ProductType, adds priceTiers field with PriceTierCollectionType + Valid + label=false. CLAUDE.md gains the convention (under Conventions): form-type tests extend Symfony\Component\Form\Test\TypeTestCase; for LiveCollectionType parents, assert button_add/button_delete view decoration. Suite: 33 -> 59 tests, 57 -> 101 assertions. ECS + PHPStan green.
1 parent cf114c3 commit c18a348

4 files changed

Lines changed: 437 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,6 @@ Wiring:
6969
- Configuration of other bundles lives in `SetonoSyliusTierPricingExtension::prepend()` as inlined PHP arrays passed to `prependExtensionConfig()`. Do not read YAML files from disk inside `prepend()` and forward the parsed result.
7070
- DI service configuration is **PHP DSL**, not XML. New services go under `config/services/<topic>.php` using `ContainerConfigurator`. Use the FQCN as the service id and alias `*Interface` to it for forward compatibility.
7171
- **Mocks: always use Prophecy** (`Prophecy\PhpUnit\ProphecyTrait` + `$this->prophesize(...)->reveal()`). Don't mix with PHPUnit's native `createMock()` / `createStub()` — keep doubles consistent across the suite. The `setono/sylius-plugin` toolchain ships `jangregor/phpstan-prophecy` so PHPStan understands `prophesize()` return types.
72+
- **Form-type tests**: extend `Symfony\Component\Form\Test\TypeTestCase` (see <https://symfony.com/doc/6.4/form/unit_testing.html>). Register the SUT + its child types via `getTypes()` (cleaner than `PreloadedExtension` when types are easily instantiable); for Sylius types that need services, prophesize the deps and pass them to the type's constructor. Use `$this->factory->create(SUT::class, $initialData)` then `submit([...])`, and assert against `isSynchronized()` + `getData()` + child-form presence with `$form->has('fieldName')`. **For form types whose parent is `LiveCollectionType`**, mirror the upstream `symfony/ux-live-component` test (`Symfony\UX\LiveComponent\Tests\Unit\Form\Type\LiveCollectionTypeTest`): call `createView()` and assert that `$view->vars['button_add']->vars['block_prefixes']` contains `live_collection_button_add` (and `button_delete` on each entry contains `live_collection_button_delete`). That's the only test that proves the Live decoration actually ran — `configureOptions()` assertions alone won't catch a regression that flips `getParent()` back to plain `CollectionType`.
7273
- Before each commit, run `composer fix-style`, `composer analyse`, and `composer phpunit` and fix what they flag. Don't commit on top of pre-existing failures.
7374
- Prefer **relative paths** in shell commands (`./tests/Application/bin/console ...`, `composer ...`). Absolute paths inside the working directory trigger Claude Code permission prompts. If you `cd` into `tests/Application/`, `cd` back to the project root before subsequent commands rather than chaining absolute paths.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Setono\SyliusTierPricingPlugin\Tests\Form\Extension;
6+
7+
use PHPUnit\Framework\Attributes\Test;
8+
use PHPUnit\Framework\TestCase;
9+
use Prophecy\Argument;
10+
use Prophecy\PhpUnit\ProphecyTrait;
11+
use Setono\SyliusTierPricingPlugin\Form\Extension\ProductTypeExtension;
12+
use Setono\SyliusTierPricingPlugin\Form\Type\PriceTierCollectionType;
13+
use Sylius\Bundle\ProductBundle\Form\Type\ProductType;
14+
use Symfony\Component\Form\FormBuilderInterface;
15+
use Symfony\Component\Validator\Constraints\Valid;
16+
17+
final class ProductTypeExtensionTest extends TestCase
18+
{
19+
use ProphecyTrait;
20+
21+
#[Test]
22+
public function it_extends_the_sylius_product_form_type(): void
23+
{
24+
self::assertSame([ProductType::class], iterator_to_array(
25+
(function (): \Generator {
26+
yield from ProductTypeExtension::getExtendedTypes();
27+
})(),
28+
));
29+
}
30+
31+
#[Test]
32+
public function it_adds_the_price_tiers_field_with_a_valid_constraint_and_no_label(): void
33+
{
34+
$builder = $this->prophesize(FormBuilderInterface::class);
35+
$builder
36+
->add(
37+
'priceTiers',
38+
PriceTierCollectionType::class,
39+
Argument::that(static function (array $options): bool {
40+
if (false !== $options['label']) {
41+
return false;
42+
}
43+
44+
if (!isset($options['constraints']) || !\is_array($options['constraints'])) {
45+
return false;
46+
}
47+
48+
return 1 === count($options['constraints']) && $options['constraints'][0] instanceof Valid;
49+
}),
50+
)
51+
->shouldBeCalledOnce()
52+
->willReturn($builder->reveal());
53+
54+
(new ProductTypeExtension())->buildForm($builder->reveal(), []);
55+
}
56+
}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Setono\SyliusTierPricingPlugin\Tests\Form\Type;
6+
7+
use PHPUnit\Framework\Attributes\Test;
8+
use Prophecy\PhpUnit\ProphecyTrait;
9+
use Setono\SyliusTierPricingPlugin\Form\Type\PriceTierCollectionType;
10+
use Setono\SyliusTierPricingPlugin\Form\Type\PriceTierType;
11+
use Setono\SyliusTierPricingPlugin\Model\PriceTier;
12+
use Sylius\Bundle\ChannelBundle\Form\Type\ChannelChoiceType;
13+
use Sylius\Bundle\ProductBundle\Form\Type\ProductVariantChoiceType;
14+
use Sylius\Component\Core\Model\Channel;
15+
use Sylius\Component\Core\Model\ChannelInterface;
16+
use Sylius\Resource\Doctrine\Persistence\RepositoryInterface;
17+
use Symfony\Component\Form\FormView;
18+
use Symfony\Component\Form\Test\TypeTestCase;
19+
use Symfony\Component\OptionsResolver\OptionsResolver;
20+
use Symfony\UX\LiveComponent\Form\Type\LiveCollectionType;
21+
22+
final class PriceTierCollectionTypeTest extends TypeTestCase
23+
{
24+
use ProphecyTrait;
25+
26+
private Channel $channel;
27+
28+
protected function setUp(): void
29+
{
30+
$this->channel = $this->channel('WEB');
31+
32+
parent::setUp();
33+
}
34+
35+
// -----------------------------------------------------------------------
36+
// configureOptions() — option defaults
37+
// -----------------------------------------------------------------------
38+
39+
#[Test]
40+
public function it_extends_live_collection_type_so_add_and_delete_fire_server_side_via_live_components(): void
41+
{
42+
self::assertSame(LiveCollectionType::class, (new PriceTierCollectionType())->getParent());
43+
}
44+
45+
#[Test]
46+
public function it_uses_price_tier_type_as_the_entry_type_with_a_blank_per_row_label(): void
47+
{
48+
$options = $this->resolveOptions();
49+
50+
self::assertSame(PriceTierType::class, $options['entry_type']);
51+
self::assertSame(['label' => false], $options['entry_options']);
52+
}
53+
54+
#[Test]
55+
public function it_allows_adding_and_deleting_rows(): void
56+
{
57+
$options = $this->resolveOptions();
58+
59+
self::assertTrue($options['allow_add']);
60+
self::assertTrue($options['allow_delete']);
61+
}
62+
63+
#[Test]
64+
public function it_disables_by_reference_so_the_owning_side_of_the_relation_is_updated_on_save(): void
65+
{
66+
self::assertFalse($this->resolveOptions()['by_reference']);
67+
}
68+
69+
#[Test]
70+
public function it_pins_block_name_to_entry_matching_the_sylius_image_collection_convention(): void
71+
{
72+
self::assertSame('entry', $this->resolveOptions()['block_name']);
73+
}
74+
75+
#[Test]
76+
public function it_labels_the_add_button_with_the_plugin_translation_key(): void
77+
{
78+
self::assertSame(
79+
['label' => 'setono_sylius_tier_pricing.ui.add_price_tier'],
80+
$this->resolveOptions()['button_add_options'],
81+
);
82+
}
83+
84+
#[Test]
85+
public function it_exposes_a_predictable_block_prefix_for_template_overrides(): void
86+
{
87+
self::assertSame(
88+
'setono_sylius_tier_pricing_price_tier_collection',
89+
(new PriceTierCollectionType())->getBlockPrefix(),
90+
);
91+
}
92+
93+
// -----------------------------------------------------------------------
94+
// Live decoration — the contract that makes Add/Delete fire over the wire.
95+
// Mirrors symfony/ux-live-component's own LiveCollectionTypeTest.
96+
// -----------------------------------------------------------------------
97+
98+
#[Test]
99+
public function the_add_button_view_carries_the_live_collection_button_add_block_prefix(): void
100+
{
101+
$blockPrefixes = $this->buttonAddView()->vars['block_prefixes'];
102+
assert(is_array($blockPrefixes));
103+
104+
self::assertContains('live_collection_button_add', $blockPrefixes);
105+
}
106+
107+
#[Test]
108+
public function the_add_button_view_emits_the_live_action_attributes_for_addCollectionItem(): void
109+
{
110+
$attr = $this->buttonAddView()->vars['attr'];
111+
assert(is_array($attr));
112+
113+
self::assertSame('live#action', $attr['data-action']);
114+
self::assertSame('addCollectionItem', $attr['data-live-action-param']);
115+
self::assertSame('price_tiers', $attr['data-live-name-param']);
116+
}
117+
118+
#[Test]
119+
public function each_entry_view_carries_a_delete_button_with_the_live_collection_button_delete_block_prefix(): void
120+
{
121+
$view = $this->factory
122+
->createNamed('price_tiers', PriceTierCollectionType::class, [new PriceTier()])
123+
->createView();
124+
self::assertCount(1, $view);
125+
126+
$buttonDelete = $view[0]->vars['button_delete'];
127+
assert($buttonDelete instanceof FormView);
128+
$blockPrefixes = $buttonDelete->vars['block_prefixes'];
129+
assert(is_array($blockPrefixes));
130+
131+
self::assertContains('live_collection_button_delete', $blockPrefixes);
132+
}
133+
134+
#[Test]
135+
public function the_add_button_label_resolves_to_the_plugin_translation_key(): void
136+
{
137+
self::assertSame(
138+
'setono_sylius_tier_pricing.ui.add_price_tier',
139+
$this->buttonAddView()->vars['label'],
140+
);
141+
}
142+
143+
// -----------------------------------------------------------------------
144+
// Data binding — submit a list of entries, verify each becomes a PriceTier.
145+
// -----------------------------------------------------------------------
146+
147+
#[Test]
148+
public function submitting_an_array_of_payloads_produces_one_price_tier_per_entry(): void
149+
{
150+
$form = $this->factory->createNamed('price_tiers', PriceTierCollectionType::class);
151+
$form->submit([
152+
['quantity' => '5', 'discount' => '10', 'channel' => 'WEB'],
153+
['quantity' => '10', 'discount' => '20', 'channel' => ''],
154+
]);
155+
156+
self::assertTrue($form->isSynchronized());
157+
158+
/** @var list<PriceTier> $tiers */
159+
$tiers = $form->getData();
160+
self::assertCount(2, $tiers);
161+
self::assertContainsOnlyInstancesOf(PriceTier::class, $tiers);
162+
self::assertSame(5, $tiers[0]->getQuantity());
163+
self::assertSame($this->channel, $tiers[0]->getChannel());
164+
self::assertSame(10, $tiers[1]->getQuantity());
165+
self::assertNull($tiers[1]->getChannel());
166+
}
167+
168+
protected function getTypes(): array
169+
{
170+
return [
171+
new PriceTierType(PriceTier::class, ['setono_sylius_tier_pricing']),
172+
new ChannelChoiceType($this->channelRepository()),
173+
new ProductVariantChoiceType(),
174+
];
175+
}
176+
177+
/**
178+
* @return array<string, mixed>
179+
*/
180+
private function resolveOptions(): array
181+
{
182+
$resolver = new OptionsResolver();
183+
(new PriceTierCollectionType())->configureOptions($resolver);
184+
185+
/** @var array<string, mixed> $resolved */
186+
$resolved = $resolver->resolve();
187+
188+
return $resolved;
189+
}
190+
191+
private function buttonAddView(): FormView
192+
{
193+
$view = $this->factory
194+
->createNamed('price_tiers', PriceTierCollectionType::class, [])
195+
->createView();
196+
$buttonAdd = $view->vars['button_add'];
197+
assert($buttonAdd instanceof FormView);
198+
199+
return $buttonAdd;
200+
}
201+
202+
/** @return RepositoryInterface<ChannelInterface> */
203+
private function channelRepository(): RepositoryInterface
204+
{
205+
$repository = $this->prophesize(RepositoryInterface::class);
206+
$repository->findAll()->willReturn([$this->channel]);
207+
208+
return $repository->reveal();
209+
}
210+
211+
private function channel(string $code): Channel
212+
{
213+
$channel = new Channel();
214+
$channel->setCode($code);
215+
$channel->setName($code);
216+
217+
return $channel;
218+
}
219+
}

0 commit comments

Comments
 (0)