feat: required/optional attribute sections in ArticleType form
Promote article_type_attributes join table to ArticleTypeAttribute entity with a required boolean flag. ArticleType gains virtual form properties (requiredAttributeDefs / optionalAttributeDefs) reconciled via applyAttributeAssignments() on persist/update. Admin form shows two Tom Select multi-selects; a small JS module (article-type-attr-sync.js) listens for ea.autocomplete.connect events and keeps the two lists mutually exclusive in real time. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f915bba966
commit
838b96eb14
10 changed files with 629 additions and 22 deletions
34
migrations/Version20260517222701.php
Normal file
34
migrations/Version20260517222701.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260517222701 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Promote article_type_attributes join table to entity with id + required flag';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE app.article_type_attributes ADD COLUMN id UUID DEFAULT gen_random_uuid() NOT NULL');
|
||||
$this->addSql('ALTER TABLE app.article_type_attributes ADD COLUMN required BOOLEAN NOT NULL DEFAULT FALSE');
|
||||
$this->addSql('ALTER TABLE app.article_type_attributes DROP CONSTRAINT article_type_attributes_pkey');
|
||||
$this->addSql('ALTER TABLE app.article_type_attributes ADD PRIMARY KEY (id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_article_type_attribute ON app.article_type_attributes (article_type_id, attribute_definition_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX app.uniq_article_type_attribute');
|
||||
$this->addSql('ALTER TABLE app.article_type_attributes DROP CONSTRAINT article_type_attributes_pkey');
|
||||
$this->addSql('ALTER TABLE app.article_type_attributes DROP COLUMN id');
|
||||
$this->addSql('ALTER TABLE app.article_type_attributes DROP COLUMN required');
|
||||
$this->addSql('ALTER TABLE app.article_type_attributes ADD PRIMARY KEY (article_type_id, attribute_definition_id)');
|
||||
}
|
||||
}
|
||||
70
public/js/admin/article-type-attr-sync.js
Normal file
70
public/js/admin/article-type-attr-sync.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
(function () {
|
||||
'use strict';
|
||||
|
||||
const instances = {};
|
||||
|
||||
function getKey(el) {
|
||||
if (el.id.endsWith('_requiredAttributeDefs')) return 'required';
|
||||
if (el.id.endsWith('_optionalAttributeDefs')) return 'optional';
|
||||
return null;
|
||||
}
|
||||
|
||||
function setupSync() {
|
||||
const req = instances.required;
|
||||
const opt = instances.optional;
|
||||
if (!req || !opt) return;
|
||||
|
||||
// Initial state: strip items already selected in the other list
|
||||
Object.keys(req.items).forEach(function (value) {
|
||||
if (opt.options[value]) {
|
||||
opt.removeOption(value);
|
||||
opt.refreshOptions(false);
|
||||
}
|
||||
});
|
||||
Object.keys(opt.items).forEach(function (value) {
|
||||
if (req.options[value]) {
|
||||
req.removeOption(value);
|
||||
req.refreshOptions(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Required → hide from Optional, restore on deselect
|
||||
req.on('item_add', function (value) {
|
||||
if (opt.options[value]) {
|
||||
opt.removeOption(value);
|
||||
opt.refreshOptions(false);
|
||||
}
|
||||
});
|
||||
req.on('item_remove', function (value) {
|
||||
var option = req.options[value];
|
||||
if (option && !opt.options[value]) {
|
||||
opt.addOption(option);
|
||||
opt.refreshOptions(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Optional → hide from Required, restore on deselect
|
||||
opt.on('item_add', function (value) {
|
||||
if (req.options[value]) {
|
||||
req.removeOption(value);
|
||||
req.refreshOptions(false);
|
||||
}
|
||||
});
|
||||
opt.on('item_remove', function (value) {
|
||||
var option = opt.options[value];
|
||||
if (option && !req.options[value]) {
|
||||
req.addOption(option);
|
||||
req.refreshOptions(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('ea.autocomplete.connect', function (event) {
|
||||
var el = event.target;
|
||||
var key = getKey(el);
|
||||
if (!key) return;
|
||||
|
||||
instances[key] = event.detail.tomSelect;
|
||||
setupSync();
|
||||
});
|
||||
}());
|
||||
|
|
@ -20,16 +20,21 @@ class ArticleType
|
|||
#[ORM\Column(type: 'string', length: 255, unique: true)]
|
||||
private string $name;
|
||||
|
||||
/** @var Collection<int, AttributeDefinition> */
|
||||
#[ORM\ManyToMany(targetEntity: AttributeDefinition::class)]
|
||||
#[ORM\JoinTable(name: 'article_type_attributes', schema: 'app')]
|
||||
private Collection $attributeDefinitions;
|
||||
/** @var Collection<int, ArticleTypeAttribute> */
|
||||
#[ORM\OneToMany(targetEntity: ArticleTypeAttribute::class, mappedBy: 'articleType', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $attributeAssignments;
|
||||
|
||||
/** @var list<AttributeDefinition>|null pending from form — applied by applyAttributeAssignments() */
|
||||
private ?array $pendingRequired = null;
|
||||
|
||||
/** @var list<AttributeDefinition>|null */
|
||||
private ?array $pendingOptional = null;
|
||||
|
||||
public function __construct(string $name)
|
||||
{
|
||||
$this->id = Uuid::v7();
|
||||
$this->name = $name;
|
||||
$this->attributeDefinitions = new ArrayCollection();
|
||||
$this->attributeAssignments = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): Uuid
|
||||
|
|
@ -47,21 +52,125 @@ class ArticleType
|
|||
$this->name = $name;
|
||||
}
|
||||
|
||||
/** @return Collection<int, ArticleTypeAttribute> */
|
||||
public function getAttributeAssignments(): Collection
|
||||
{
|
||||
return $this->attributeAssignments;
|
||||
}
|
||||
|
||||
/** All attribute definitions regardless of required flag — used by pipeline agents. */
|
||||
/** @return Collection<int, AttributeDefinition> */
|
||||
public function getAttributeDefinitions(): Collection
|
||||
{
|
||||
return $this->attributeDefinitions;
|
||||
return $this->attributeAssignments->map(
|
||||
fn (ArticleTypeAttribute $a) => $a->getAttributeDefinition()
|
||||
);
|
||||
}
|
||||
|
||||
/** @return Collection<int, AttributeDefinition> */
|
||||
public function getRequiredAttributeDefinitions(): Collection
|
||||
{
|
||||
return $this->attributeAssignments
|
||||
->filter(fn (ArticleTypeAttribute $a) => $a->isRequired())
|
||||
->map(fn (ArticleTypeAttribute $a) => $a->getAttributeDefinition());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Virtual form properties — collected by the admin form, applied on persist
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** @return Collection<int, AttributeDefinition> */
|
||||
public function getRequiredAttributeDefs(): Collection
|
||||
{
|
||||
return $this->attributeAssignments
|
||||
->filter(fn (ArticleTypeAttribute $a) => $a->isRequired())
|
||||
->map(fn (ArticleTypeAttribute $a) => $a->getAttributeDefinition());
|
||||
}
|
||||
|
||||
/** @param iterable<AttributeDefinition> $defs */
|
||||
public function setRequiredAttributeDefs(iterable $defs): void
|
||||
{
|
||||
$this->pendingRequired = $defs instanceof Collection ? $defs->toArray() : \iterator_to_array($defs, false);
|
||||
}
|
||||
|
||||
/** @return Collection<int, AttributeDefinition> */
|
||||
public function getOptionalAttributeDefs(): Collection
|
||||
{
|
||||
return $this->attributeAssignments
|
||||
->filter(fn (ArticleTypeAttribute $a) => !$a->isRequired())
|
||||
->map(fn (ArticleTypeAttribute $a) => $a->getAttributeDefinition());
|
||||
}
|
||||
|
||||
/** @param iterable<AttributeDefinition> $defs */
|
||||
public function setOptionalAttributeDefs(iterable $defs): void
|
||||
{
|
||||
$this->pendingOptional = $defs instanceof Collection ? $defs->toArray() : \iterator_to_array($defs, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile attributeAssignments from pending form data.
|
||||
* Call this in persistEntity / updateEntity before flushing.
|
||||
*/
|
||||
public function applyAttributeAssignments(): void
|
||||
{
|
||||
if ($this->pendingRequired === null && $this->pendingOptional === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$required = $this->pendingRequired ?? $this->getRequiredAttributeDefs()->toArray();
|
||||
$optional = $this->pendingOptional ?? $this->getOptionalAttributeDefs()->toArray();
|
||||
|
||||
// desired: defId => required bool
|
||||
$desired = [];
|
||||
foreach ($required as $def) {
|
||||
$desired[$def->getId()->toRfc4122()] = ['def' => $def, 'required' => true];
|
||||
}
|
||||
foreach ($optional as $def) {
|
||||
$desired[$def->getId()->toRfc4122()] = ['def' => $def, 'required' => false];
|
||||
}
|
||||
|
||||
// Update or remove existing assignments
|
||||
foreach ($this->attributeAssignments as $assignment) {
|
||||
$id = $assignment->getAttributeDefinition()->getId()->toRfc4122();
|
||||
if (isset($desired[$id])) {
|
||||
$assignment->setRequired($desired[$id]['required']);
|
||||
unset($desired[$id]);
|
||||
} else {
|
||||
$this->attributeAssignments->removeElement($assignment);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new assignments
|
||||
foreach ($desired as $item) {
|
||||
$this->attributeAssignments->add(new ArticleTypeAttribute($this, $item['def'], $item['required']));
|
||||
}
|
||||
|
||||
$this->pendingRequired = null;
|
||||
$this->pendingOptional = null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Programmatic helpers (used by ArticleTypeService and tests)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function addAttributeDefinition(AttributeDefinition $def): void
|
||||
{
|
||||
if (!$this->attributeDefinitions->contains($def)) {
|
||||
$this->attributeDefinitions->add($def);
|
||||
foreach ($this->attributeAssignments as $assignment) {
|
||||
if ($assignment->getAttributeDefinition() === $def) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
$this->attributeAssignments->add(new ArticleTypeAttribute($this, $def));
|
||||
}
|
||||
|
||||
public function removeAttributeDefinition(AttributeDefinition $def): void
|
||||
{
|
||||
$this->attributeDefinitions->removeElement($def);
|
||||
foreach ($this->attributeAssignments as $assignment) {
|
||||
if ($assignment->getAttributeDefinition() === $def) {
|
||||
$this->attributeAssignments->removeElement($assignment);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
src/Domain/Article/ArticleTypeAttribute.php
Normal file
62
src/Domain/Article/ArticleTypeAttribute.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Article;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'article_type_attributes', schema: 'app')]
|
||||
#[ORM\UniqueConstraint(columns: ['article_type_id', 'attribute_definition_id'])]
|
||||
class ArticleTypeAttribute
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: 'uuid')]
|
||||
private Uuid $id;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ArticleType::class, inversedBy: 'attributeAssignments')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private ArticleType $articleType;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: AttributeDefinition::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private AttributeDefinition $attributeDefinition;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $required = false;
|
||||
|
||||
public function __construct(ArticleType $articleType, AttributeDefinition $attributeDefinition, bool $required = false)
|
||||
{
|
||||
$this->id = Uuid::v7();
|
||||
$this->articleType = $articleType;
|
||||
$this->attributeDefinition = $attributeDefinition;
|
||||
$this->required = $required;
|
||||
}
|
||||
|
||||
public function getId(): Uuid
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getArticleType(): ArticleType
|
||||
{
|
||||
return $this->articleType;
|
||||
}
|
||||
|
||||
public function getAttributeDefinition(): AttributeDefinition
|
||||
{
|
||||
return $this->attributeDefinition;
|
||||
}
|
||||
|
||||
public function isRequired(): bool
|
||||
{
|
||||
return $this->required;
|
||||
}
|
||||
|
||||
public function setRequired(bool $required): void
|
||||
{
|
||||
$this->required = $required;
|
||||
}
|
||||
}
|
||||
|
|
@ -60,6 +60,11 @@ class AttributeDefinition
|
|||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(AttributeType $type): void
|
||||
{
|
||||
$this->type = $type;
|
||||
}
|
||||
|
||||
public function getUnit(): ?string
|
||||
{
|
||||
return $this->unit;
|
||||
|
|
|
|||
|
|
@ -5,13 +5,16 @@ declare(strict_types=1);
|
|||
namespace App\Infrastructure\Http\Controller\Admin;
|
||||
|
||||
use App\Domain\Article\ArticleType;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
|
||||
use App\Domain\Article\AttributeDefinition;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Assets;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\Field;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
|
||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||
|
||||
/** @extends AbstractCrudController<ArticleType> */
|
||||
final class ArticleTypeCrudController extends AbstractCrudController
|
||||
|
|
@ -26,23 +29,61 @@ final class ArticleTypeCrudController extends AbstractCrudController
|
|||
return $crud->setEntityLabelInSingular('Article Type')->setEntityLabelInPlural('Article Types');
|
||||
}
|
||||
|
||||
public function configureAssets(Assets $assets): Assets
|
||||
{
|
||||
return $assets->addJsFile('js/admin/article-type-attr-sync.js');
|
||||
}
|
||||
|
||||
public function createEntity(string $entityFqcn): ArticleType
|
||||
{
|
||||
return new ArticleType('');
|
||||
}
|
||||
|
||||
public function configureActions(Actions $actions): Actions
|
||||
{
|
||||
return $actions;
|
||||
}
|
||||
|
||||
public function configureFields(string $pageName): iterable
|
||||
{
|
||||
yield IdField::new('id')->hideOnForm();
|
||||
yield TextField::new('name', 'Name');
|
||||
yield AssociationField::new('attributeDefinitions', 'Attributes')
|
||||
->setFormTypeOptions(['by_reference' => false])
|
||||
->autocomplete()
|
||||
yield IntegerField::new('attributeAssignments', '# Attributes')
|
||||
->formatValue(static fn (mixed $v): int => is_countable($v) ? count($v) : 0)
|
||||
->hideOnForm()
|
||||
->setSortable(false);
|
||||
|
||||
yield Field::new('requiredAttributeDefs', 'Required Attributes')
|
||||
->setFormType(EntityType::class)
|
||||
->setFormTypeOptions([
|
||||
'class' => AttributeDefinition::class,
|
||||
'multiple' => true,
|
||||
'choice_label' => 'name',
|
||||
'required' => false,
|
||||
'by_reference' => false,
|
||||
'attr' => ['data-ea-widget' => 'ea-autocomplete'],
|
||||
])
|
||||
->hideOnIndex();
|
||||
|
||||
yield Field::new('optionalAttributeDefs', 'Optional Attributes')
|
||||
->setFormType(EntityType::class)
|
||||
->setFormTypeOptions([
|
||||
'class' => AttributeDefinition::class,
|
||||
'multiple' => true,
|
||||
'choice_label' => 'name',
|
||||
'required' => false,
|
||||
'by_reference' => false,
|
||||
'attr' => ['data-ea-widget' => 'ea-autocomplete'],
|
||||
])
|
||||
->hideOnIndex();
|
||||
}
|
||||
|
||||
public function persistEntity(EntityManagerInterface $entityManager, mixed $entityInstance): void
|
||||
{
|
||||
\assert($entityInstance instanceof ArticleType);
|
||||
$entityInstance->applyAttributeAssignments();
|
||||
parent::persistEntity($entityManager, $entityInstance);
|
||||
}
|
||||
|
||||
public function updateEntity(EntityManagerInterface $entityManager, mixed $entityInstance): void
|
||||
{
|
||||
\assert($entityInstance instanceof ArticleType);
|
||||
$entityInstance->applyAttributeAssignments();
|
||||
parent::updateEntity($entityManager, $entityInstance);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
53
tests/Unit/Domain/Article/ArticleTypeAttributeTest.php
Normal file
53
tests/Unit/Domain/Article/ArticleTypeAttributeTest.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Domain\Article;
|
||||
|
||||
use App\Domain\Article\ArticleType;
|
||||
use App\Domain\Article\ArticleTypeAttribute;
|
||||
use App\Domain\Article\AttributeDefinition;
|
||||
use App\Domain\Article\AttributeType;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ArticleTypeAttributeTest extends TestCase
|
||||
{
|
||||
private ArticleType $type;
|
||||
private AttributeDefinition $def;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->type = new ArticleType('Laptop');
|
||||
$this->def = new AttributeDefinition('RAM', AttributeType::Select);
|
||||
}
|
||||
|
||||
public function testNotRequiredByDefault(): void
|
||||
{
|
||||
$assignment = new ArticleTypeAttribute($this->type, $this->def);
|
||||
|
||||
self::assertFalse($assignment->isRequired());
|
||||
}
|
||||
|
||||
public function testCanBeCreatedAsRequired(): void
|
||||
{
|
||||
$assignment = new ArticleTypeAttribute($this->type, $this->def, required: true);
|
||||
|
||||
self::assertTrue($assignment->isRequired());
|
||||
}
|
||||
|
||||
public function testSetRequired(): void
|
||||
{
|
||||
$assignment = new ArticleTypeAttribute($this->type, $this->def);
|
||||
$assignment->setRequired(true);
|
||||
|
||||
self::assertTrue($assignment->isRequired());
|
||||
}
|
||||
|
||||
public function testReferencesCorrectTypeAndDefinition(): void
|
||||
{
|
||||
$assignment = new ArticleTypeAttribute($this->type, $this->def);
|
||||
|
||||
self::assertSame($this->type, $assignment->getArticleType());
|
||||
self::assertSame($this->def, $assignment->getAttributeDefinition());
|
||||
}
|
||||
}
|
||||
156
tests/Unit/Domain/Article/ArticleTypeRequiredAttributesTest.php
Normal file
156
tests/Unit/Domain/Article/ArticleTypeRequiredAttributesTest.php
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Domain\Article;
|
||||
|
||||
use App\Domain\Article\ArticleType;
|
||||
use App\Domain\Article\ArticleTypeAttribute;
|
||||
use App\Domain\Article\AttributeDefinition;
|
||||
use App\Domain\Article\AttributeType;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ArticleTypeRequiredAttributesTest extends TestCase
|
||||
{
|
||||
private ArticleType $type;
|
||||
private AttributeDefinition $ram;
|
||||
private AttributeDefinition $cpu;
|
||||
private AttributeDefinition $color;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->type = new ArticleType('Laptop');
|
||||
$this->ram = new AttributeDefinition('RAM', AttributeType::Select);
|
||||
$this->cpu = new AttributeDefinition('CPU', AttributeType::String);
|
||||
$this->color = new AttributeDefinition('Color', AttributeType::String);
|
||||
}
|
||||
|
||||
public function testGetAttributeDefinitionsReturnsAll(): void
|
||||
{
|
||||
$this->type->addAttributeDefinition($this->ram);
|
||||
$this->type->addAttributeDefinition($this->cpu);
|
||||
|
||||
self::assertCount(2, $this->type->getAttributeDefinitions());
|
||||
}
|
||||
|
||||
public function testGetRequiredAttributeDefinitionsReturnsOnlyRequired(): void
|
||||
{
|
||||
$this->type->addAttributeDefinition($this->ram);
|
||||
$this->type->addAttributeDefinition($this->cpu);
|
||||
$this->type->addAttributeDefinition($this->color);
|
||||
|
||||
// mark RAM and CPU required
|
||||
foreach ($this->type->getAttributeAssignments() as $a) {
|
||||
if ($a->getAttributeDefinition() === $this->ram || $a->getAttributeDefinition() === $this->cpu) {
|
||||
$a->setRequired(true);
|
||||
}
|
||||
}
|
||||
|
||||
$required = $this->type->getRequiredAttributeDefinitions();
|
||||
self::assertCount(2, $required);
|
||||
self::assertContains($this->ram, $required->toArray());
|
||||
self::assertContains($this->cpu, $required->toArray());
|
||||
self::assertNotContains($this->color, $required->toArray());
|
||||
}
|
||||
|
||||
public function testAddAttributeDefinitionIdempotent(): void
|
||||
{
|
||||
$this->type->addAttributeDefinition($this->ram);
|
||||
$this->type->addAttributeDefinition($this->ram);
|
||||
|
||||
self::assertCount(1, $this->type->getAttributeDefinitions());
|
||||
}
|
||||
|
||||
public function testRemoveAttributeDefinition(): void
|
||||
{
|
||||
$this->type->addAttributeDefinition($this->ram);
|
||||
$this->type->addAttributeDefinition($this->cpu);
|
||||
$this->type->removeAttributeDefinition($this->ram);
|
||||
|
||||
$defs = $this->type->getAttributeDefinitions()->toArray();
|
||||
self::assertCount(1, $defs);
|
||||
self::assertNotContains($this->ram, $defs);
|
||||
self::assertContains($this->cpu, $defs);
|
||||
}
|
||||
|
||||
public function testNoRequiredAttributesReturnsEmpty(): void
|
||||
{
|
||||
$this->type->addAttributeDefinition($this->ram);
|
||||
|
||||
self::assertCount(0, $this->type->getRequiredAttributeDefinitions());
|
||||
}
|
||||
|
||||
public function testApplyAttributeAssignmentsCreatesAssignments(): void
|
||||
{
|
||||
$this->type->setRequiredAttributeDefs([$this->ram, $this->cpu]);
|
||||
$this->type->setOptionalAttributeDefs([$this->color]);
|
||||
$this->type->applyAttributeAssignments();
|
||||
|
||||
self::assertCount(3, $this->type->getAttributeDefinitions());
|
||||
$required = $this->type->getRequiredAttributeDefinitions()->toArray();
|
||||
self::assertCount(2, $required);
|
||||
self::assertContains($this->ram, $required);
|
||||
self::assertContains($this->cpu, $required);
|
||||
self::assertNotContains($this->color, $required);
|
||||
}
|
||||
|
||||
public function testApplyAttributeAssignmentsIsNoopWhenNothingPending(): void
|
||||
{
|
||||
$this->type->addAttributeDefinition($this->ram);
|
||||
$this->type->applyAttributeAssignments();
|
||||
|
||||
self::assertCount(1, $this->type->getAttributeDefinitions());
|
||||
}
|
||||
|
||||
public function testApplyAttributeAssignmentsUpdatesRequiredFlag(): void
|
||||
{
|
||||
$this->type->addAttributeDefinition($this->ram);
|
||||
self::assertCount(0, $this->type->getRequiredAttributeDefinitions());
|
||||
|
||||
$this->type->setRequiredAttributeDefs([$this->ram]);
|
||||
$this->type->setOptionalAttributeDefs([]);
|
||||
$this->type->applyAttributeAssignments();
|
||||
|
||||
$required = $this->type->getRequiredAttributeDefinitions()->toArray();
|
||||
self::assertCount(1, $required);
|
||||
self::assertContains($this->ram, $required);
|
||||
}
|
||||
|
||||
public function testApplyAttributeAssignmentsRemovesUnlistedAttributes(): void
|
||||
{
|
||||
$this->type->setRequiredAttributeDefs([$this->ram, $this->cpu]);
|
||||
$this->type->setOptionalAttributeDefs([]);
|
||||
$this->type->applyAttributeAssignments();
|
||||
self::assertCount(2, $this->type->getAttributeDefinitions());
|
||||
|
||||
$this->type->setRequiredAttributeDefs([$this->ram]);
|
||||
$this->type->setOptionalAttributeDefs([]);
|
||||
$this->type->applyAttributeAssignments();
|
||||
|
||||
$defs = $this->type->getAttributeDefinitions()->toArray();
|
||||
self::assertCount(1, $defs);
|
||||
self::assertContains($this->ram, $defs);
|
||||
self::assertNotContains($this->cpu, $defs);
|
||||
}
|
||||
|
||||
public function testApplyAttributeAssignmentsSwapsRequiredAndOptional(): void
|
||||
{
|
||||
// Set ram=required, cpu=optional
|
||||
$this->type->setRequiredAttributeDefs([$this->ram]);
|
||||
$this->type->setOptionalAttributeDefs([$this->cpu]);
|
||||
$this->type->applyAttributeAssignments();
|
||||
|
||||
// Swap: ram=optional, cpu=required
|
||||
$this->type->setRequiredAttributeDefs([$this->cpu]);
|
||||
$this->type->setOptionalAttributeDefs([$this->ram]);
|
||||
$this->type->applyAttributeAssignments();
|
||||
|
||||
$required = $this->type->getRequiredAttributeDefinitions()->toArray();
|
||||
self::assertContains($this->cpu, $required);
|
||||
self::assertNotContains($this->ram, $required);
|
||||
|
||||
$optional = $this->type->getOptionalAttributeDefs()->toArray();
|
||||
self::assertContains($this->ram, $optional);
|
||||
self::assertNotContains($this->cpu, $optional);
|
||||
}
|
||||
}
|
||||
67
tests/Unit/Domain/Article/AttributeDefinitionTest.php
Normal file
67
tests/Unit/Domain/Article/AttributeDefinitionTest.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Domain\Article;
|
||||
|
||||
use App\Domain\Article\AttributeDefinition;
|
||||
use App\Domain\Article\AttributeType;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class AttributeDefinitionTest extends TestCase
|
||||
{
|
||||
public function testConstructorSetsNameAndType(): void
|
||||
{
|
||||
$def = new AttributeDefinition('RAM', AttributeType::Select);
|
||||
|
||||
self::assertSame('RAM', $def->getName());
|
||||
self::assertSame(AttributeType::Select, $def->getType());
|
||||
self::assertNull($def->getUnit());
|
||||
self::assertNull($def->getOptions());
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider allAttributeTypes
|
||||
*/
|
||||
public function testSetTypeMutatesType(AttributeType $type): void
|
||||
{
|
||||
$def = new AttributeDefinition('Field', AttributeType::String);
|
||||
$def->setType($type);
|
||||
|
||||
self::assertSame($type, $def->getType());
|
||||
}
|
||||
|
||||
/** @return array<string, array{AttributeType}> */
|
||||
public static function allAttributeTypes(): array
|
||||
{
|
||||
return array_combine(
|
||||
array_map(fn (AttributeType $t) => $t->value, AttributeType::cases()),
|
||||
array_map(fn (AttributeType $t) => [$t], AttributeType::cases()),
|
||||
);
|
||||
}
|
||||
|
||||
public function testSetNameAndUnit(): void
|
||||
{
|
||||
$def = new AttributeDefinition('Weight', AttributeType::Float);
|
||||
$def->setName('Mass');
|
||||
$def->setUnit('kg');
|
||||
|
||||
self::assertSame('Mass', $def->getName());
|
||||
self::assertSame('kg', $def->getUnit());
|
||||
}
|
||||
|
||||
public function testSetOptions(): void
|
||||
{
|
||||
$def = new AttributeDefinition('Grade', AttributeType::Select);
|
||||
$def->setOptions(['A', 'B', 'C']);
|
||||
|
||||
self::assertSame(['A', 'B', 'C'], $def->getOptions());
|
||||
}
|
||||
|
||||
public function testToStringReturnsName(): void
|
||||
{
|
||||
$def = new AttributeDefinition('CPU', AttributeType::String);
|
||||
|
||||
self::assertSame('CPU', (string) $def);
|
||||
}
|
||||
}
|
||||
|
|
@ -30,12 +30,22 @@ final class ArticleTypeCrudControllerTest extends TestCase
|
|||
self::assertSame('', $entity->getName());
|
||||
}
|
||||
|
||||
public function testConfigureFieldsContainsNameAndAttributes(): void
|
||||
public function testConfigureFieldsContainsExpectedProperties(): void
|
||||
{
|
||||
$fields = iterator_to_array($this->controller->configureFields('new'));
|
||||
$names = array_map(fn ($f) => $f->getAsDto()->getProperty(), $fields);
|
||||
|
||||
self::assertContains('name', $names);
|
||||
self::assertContains('attributeDefinitions', $names);
|
||||
self::assertContains('requiredAttributeDefs', $names);
|
||||
self::assertContains('optionalAttributeDefs', $names);
|
||||
}
|
||||
|
||||
public function testConfigureFieldsForIndexContainsNameAndCount(): void
|
||||
{
|
||||
$fields = iterator_to_array($this->controller->configureFields('index'));
|
||||
$names = array_map(fn ($f) => $f->getAsDto()->getProperty(), $fields);
|
||||
|
||||
self::assertContains('name', $names);
|
||||
self::assertContains('attributeAssignments', $names);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue