From 4958eb8698da2e044bee1d5beca0a80b14e5a5ec Mon Sep 17 00:00:00 2001 From: david Date: Wed, 20 Mar 2024 12:03:12 +0100 Subject: [PATCH] #12 add list object for sorting, filtering and pagination - remove unused category grouping from result-set - add caching incl. CacheWarmer --- config/documentation.php | 5 + src/Cache/ComponentsWarmer.php | 30 +++ src/Component/ComponentCategory.php | 7 +- src/Component/ComponentItemList.php | 104 +++++++++ src/Controller/TwigDocController.php | 2 +- src/DependencyInjection/TwigDocExtension.php | 1 + src/Service/ComponentService.php | 146 +++++------- src/Twig/TwigDocExtension.php | 3 +- templates/blocks/page_blocks.html.twig | 8 +- .../component/_invalid_component.html.twig | 40 ++-- .../Controller/TwigDocControllerTest.php | 2 + .../Service/ComponentServiceTest.php | 44 ++-- .../Functional/Twig/TwigDocExtensionTest.php | 2 + .../Unit/Component/ComponentItemListTest.php | 207 ++++++++++++++++++ 14 files changed, 453 insertions(+), 148 deletions(-) create mode 100644 src/Cache/ComponentsWarmer.php create mode 100644 src/Component/ComponentItemList.php create mode 100644 tests/Unit/Component/ComponentItemListTest.php diff --git a/config/documentation.php b/config/documentation.php index ccf9d71..97ab907 100644 --- a/config/documentation.php +++ b/config/documentation.php @@ -4,6 +4,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Qossmic\TwigDocBundle\Cache\ComponentsWarmer; use Qossmic\TwigDocBundle\Component\ComponentItemFactory; use Qossmic\TwigDocBundle\Controller\TwigDocController; use Qossmic\TwigDocBundle\Service\CategoryService; @@ -36,5 +37,9 @@ ->autowire() ->tag('twig.extension') ->alias(TwigDocExtension::class, 'twig_doc.twig.extension') + + ->set('twig_doc.cache_warmer', ComponentsWarmer::class) + ->arg('$container', service('service_container')) + ->tag('kernel.cache_warmer') ; }; diff --git a/src/Cache/ComponentsWarmer.php b/src/Cache/ComponentsWarmer.php new file mode 100644 index 0000000..68068c2 --- /dev/null +++ b/src/Cache/ComponentsWarmer.php @@ -0,0 +1,30 @@ +container->get('twig_doc.service.component'); + + if ($componentService instanceof ComponentService) { + $componentService->getComponents(); + } + + return []; + } +} diff --git a/src/Component/ComponentCategory.php b/src/Component/ComponentCategory.php index fa3cf7e..2eff9f5 100644 --- a/src/Component/ComponentCategory.php +++ b/src/Component/ComponentCategory.php @@ -9,7 +9,7 @@ /** * @codeCoverageIgnore */ -class ComponentCategory +class ComponentCategory implements \Stringable { public const DEFAULT_CATEGORY = 'Components'; @@ -41,4 +41,9 @@ public function setName(string $name): self return $this; } + + public function __toString(): string + { + return $this->name; + } } diff --git a/src/Component/ComponentItemList.php b/src/Component/ComponentItemList.php new file mode 100644 index 0000000..64ca5d3 --- /dev/null +++ b/src/Component/ComponentItemList.php @@ -0,0 +1,104 @@ +getArrayCopy(), $start, $limit); + } + + public function sort(string $field, string $direction = self::SORT_ASC): void + { + if (!\in_array($field, $this->sortableFields)) { + throw new \InvalidArgumentException(sprintf('field "%s" is not sortable', $field)); + } + + $method = sprintf('get%s', ucfirst($field)); + + $this->uasort(function (ComponentItem $item, ComponentItem $item2) use ($method, $direction) { + if ($direction === self::SORT_DESC) { + return \call_user_func([$item2, $method]) <=> \call_user_func([$item, $method]); + } + + return \call_user_func([$item, $method]) <=> \call_user_func([$item2, $method]); + }); + } + + public function filter(string $query, ?string $type): self + { + $components = []; + switch ($type) { + case 'category': + $components = array_filter( + $this->getArrayCopy(), + function (ComponentItem $item) use ($query) { + $category = $item->getCategory()->getName(); + $parent = $item->getCategory()->getParent(); + while ($parent !== null) { + $category = $parent->getName(); + $parent = $parent->getParent(); + } + + return strtolower($category) === strtolower($query); + } + ); + + break; + case 'sub_category': + $components = array_filter( + $this->getArrayCopy(), + fn (ComponentItem $item) => $item->getCategory()->getParent() !== null + && strtolower($item->getCategory()->getName()) === strtolower($query) + ); + + break; + case 'tags': + $tags = array_map('trim', explode(',', strtolower($query))); + $components = array_filter($this->getArrayCopy(), function (ComponentItem $item) use ($tags) { + return array_intersect($tags, array_map('strtolower', $item->getTags())) !== []; + }); + + break; + case 'name': + $components = array_filter( + $this->getArrayCopy(), + fn (ComponentItem $item) => str_contains(strtolower($item->getName()), strtolower($query)) + ); + + break; + default: + foreach (['category', 'sub_category', 'tags', 'name'] as $type) { + $components = array_merge($components, (array) $this->filter($query, $type)); + } + + break; + } + + return new self(array_unique($components, \SORT_REGULAR)); + } +} diff --git a/src/Controller/TwigDocController.php b/src/Controller/TwigDocController.php index a47c05d..cbed517 100644 --- a/src/Controller/TwigDocController.php +++ b/src/Controller/TwigDocController.php @@ -22,7 +22,7 @@ public function __construct( public function index(Request $request): Response { - $components = $this->componentService->getCategories(); + $components = $this->componentService->getComponents(); if ($filterQuery = $request->query->get('filterQuery')) { $filterType = $request->query->get('filterType'); diff --git a/src/DependencyInjection/TwigDocExtension.php b/src/DependencyInjection/TwigDocExtension.php index 9592ce3..602679f 100644 --- a/src/DependencyInjection/TwigDocExtension.php +++ b/src/DependencyInjection/TwigDocExtension.php @@ -22,6 +22,7 @@ public function load(array $configs, ContainerBuilder $container): void $definition = $container->getDefinition('twig_doc.service.component'); $definition->setArgument('$componentsConfig', $config['components']); + $definition->setArgument('$configReadTime', time()); $categories = array_merge([['name' => ComponentCategory::DEFAULT_CATEGORY]], $config['categories']); diff --git a/src/Service/ComponentService.php b/src/Service/ComponentService.php index 40b596f..c359dc9 100644 --- a/src/Service/ComponentService.php +++ b/src/Service/ComponentService.php @@ -4,135 +4,95 @@ namespace Qossmic\TwigDocBundle\Service; +use Psr\Cache\InvalidArgumentException; use Qossmic\TwigDocBundle\Component\ComponentInvalid; use Qossmic\TwigDocBundle\Component\ComponentItem; use Qossmic\TwigDocBundle\Component\ComponentItemFactory; +use Qossmic\TwigDocBundle\Component\ComponentItemList; use Qossmic\TwigDocBundle\Exception\InvalidComponentConfigurationException; +use Symfony\Contracts\Cache\CacheInterface; class ComponentService { - /** - * @var ComponentItem[] - */ - private array $components = []; - - /** - * @var array> - */ - private array $categories = []; - - /** - * @var ComponentInvalid[] - */ - private array $invalidComponents = []; - public function __construct( private readonly ComponentItemFactory $itemFactory, private readonly array $componentsConfig, + private readonly CacheInterface $cache, + private readonly int $configReadTime = 0 ) { - $this->parse(); } /** - * @return ComponentItem[] + * @return ComponentItemList */ - public function getComponentsByCategory(string $category): array + public function getComponentsByCategory(string $category): ComponentItemList { - return $this->categories[$category] ?? []; + return $this->filter($category, 'category'); } /** - * @return array> + * @throws InvalidArgumentException */ - public function getCategories(): array + public function getComponents(): ComponentItemList { - return $this->categories; - } - - private function parse(): void - { - $components = $categories = $invalidComponents = []; - - foreach ($this->componentsConfig as $componentData) { - try { - $item = $this->itemFactory->create($componentData); - } catch (InvalidComponentConfigurationException $e) { - $item = new ComponentInvalid($e->getViolationList(), $componentData); - $invalidComponents[] = $item; - continue; - } - $components[] = $item; - $categories[$item->getMainCategory()->getName()][] = $item; - } - - $this->components = $components; - $this->categories = $categories; - $this->invalidComponents = $invalidComponents; - } - - public function filter(string $filterQuery, string $filterType): array - { - $components = array_unique($this->filterComponents($filterQuery, $filterType), \SORT_REGULAR); - - $result = []; - - foreach ($components as $component) { - $result[$component->getMainCategory()->getName()][] = $component; - } + return new ComponentItemList( + $this->cache->get('twig_doc.parsed.components'.$this->configReadTime, function () { + $components = []; + foreach ($this->componentsConfig as $componentData) { + try { + $components[] = $this->itemFactory->create($componentData); + } catch (InvalidComponentConfigurationException) { + continue; + } + } - return $result; + return $components; + }) + ); } - private function filterComponents(string $filterQuery, string $filterType): array + public function filter(string $filterQuery, string $filterType): ComponentItemList { - $components = []; - switch ($filterType) { - case 'category': - $components = array_filter($this->categories, fn (string $category) => strtolower($category) === strtolower($filterQuery), \ARRAY_FILTER_USE_KEY); - - return $components[array_key_first($components)] ?? []; - case 'sub_category': - $components = array_filter( - $this->components, - fn (ComponentItem $item) => $item->getCategory()->getParent() !== null - && strtolower($item->getCategory()->getName()) === strtolower($filterQuery) - ); - - break; - case 'tags': - $tags = array_map('trim', explode(',', strtolower($filterQuery))); - $components = array_filter($this->components, function (ComponentItem $item) use ($tags) { - return array_intersect($tags, array_map('strtolower', $item->getTags())) !== []; - }); - - break; - case 'name': - $components = array_filter( - $this->components, - fn (ComponentItem $item) => str_contains(strtolower($item->getName()), strtolower($filterQuery))); - - break; - default: - foreach (['category', 'sub_category', 'tags', 'name'] as $type) { - $components = array_merge($components, $this->filterComponents($filterQuery, $type)); - } - - break; - } + $hash = sprintf('twig_doc_bundle.search.%s.%s', md5($filterQuery.$filterType), $this->configReadTime); - return $components; + return $this->cache->get($hash, function () use ($filterQuery, $filterType) { + return $this->getComponents()->filter($filterQuery, $filterType); + }); } /** * @return ComponentInvalid[] + * + * @throws InvalidArgumentException */ public function getInvalidComponents(): array { - return $this->invalidComponents; + return $this->cache->get('twig_doc_bundle.invalid_components'.$this->configReadTime, function () { + $invalid = array_filter($this->componentsConfig, function ($cmpData) { + foreach ($this->getComponents()->getArrayCopy() as $cmp) { + if ($cmp->getName() === $cmpData['name'] ?? null) { + return false; + } + } + + return true; + }); + $invalidComponents = []; + + foreach ($invalid as $cmpData) { + try { + $this->itemFactory->create($cmpData); + } catch (InvalidComponentConfigurationException $e) { + $invalidComponents[] = new ComponentInvalid($e->getViolationList(), $cmpData); + } + } + + return $invalidComponents; + }); } public function getComponent(string $name): ?ComponentItem { - return array_values(array_filter($this->components, fn (ComponentItem $c) => $c->getName() === $name))[0] ?? null; + return array_values(array_filter((array) $this->getComponents(), fn (ComponentItem $c) => $c->getName() === $name))[0] ?? null; } } diff --git a/src/Twig/TwigDocExtension.php b/src/Twig/TwigDocExtension.php index 19e0dd2..26eb0c4 100644 --- a/src/Twig/TwigDocExtension.php +++ b/src/Twig/TwigDocExtension.php @@ -7,6 +7,7 @@ use Qossmic\TwigDocBundle\Component\ComponentCategory; use Qossmic\TwigDocBundle\Component\ComponentInvalid; use Qossmic\TwigDocBundle\Component\ComponentItem; +use Qossmic\TwigDocBundle\Component\ComponentItemList; use Qossmic\TwigDocBundle\Service\CategoryService; use Qossmic\TwigDocBundle\Service\ComponentService; use Symfony\UX\TwigComponent\ComponentRendererInterface; @@ -37,7 +38,7 @@ public function getFunctions(): array ]; } - public function filterComponents(string $filterQuery, ?string $type = null): array + public function filterComponents(string $filterQuery, ?string $type = null): ComponentItemList { return $this->componentService->filter($filterQuery, $type); } diff --git a/templates/blocks/page_blocks.html.twig b/templates/blocks/page_blocks.html.twig index b17b065..9951789 100644 --- a/templates/blocks/page_blocks.html.twig +++ b/templates/blocks/page_blocks.html.twig @@ -63,12 +63,8 @@ - {% for items in components %} -
- {% for item in items %} - {% include '@TwigDoc/component/_item.html.twig' with { component: item } %} - {% endfor %} -
+ {% for component in components %} + {% include '@TwigDoc/component/_item.html.twig' with { component: component } %} {% endfor %} diff --git a/templates/component/_invalid_component.html.twig b/templates/component/_invalid_component.html.twig index 4f8b6a9..68dc9b5 100644 --- a/templates/component/_invalid_component.html.twig +++ b/templates/component/_invalid_component.html.twig @@ -1,5 +1,7 @@

{{ component.originalConfig.name }}

+

Path

+

{{ component.originalConfig.path ?? 'UNKNOWN PATH' }}

Error-Details:

{{ component.violationList }} @@ -9,27 +11,33 @@

Title: {{ component.originalConfig.title ?? '' }}

Description: {{ component.originalConfig.description ?? '' }}

Parameters:

- {% if component.originalConfig.parameters %} + {% for key, type in component.originalConfig.parameters ?? [] %} + {% if loop.first %}
    - {% for key, type in component.originalConfig.parameters %} -
  • {{ key }}: {{ type }}
  • - {% endfor %} + {% endif %} +
  • {{ key }}: {{ type }}
  • + {% if loop.last %}
- {% endif %} + {% endif %} + {% endfor %}

Tags:

- {% if component.originalConfig.tags %} -
    - {% for tag in component.originalConfig.tags %} + {% for tag in component.originalConfig.tags %} + {% if loop.first %} +
      + {% endif %}
    • {{ tag }}
    • - {% endfor %} -
    - {% endif %} + {% if loop.last %} +
+ {% endif %} + {% endfor %}

Variations:

- {% for variation in component.originalConfig.variations ?? [] %} + {% for key, value in variation %} + {% if loop.first %}
    - {% for key, value in variation %} -
  • {{ key }}: {{ value }}
  • - {% endfor %} -
+ {% endif %} +
  • {{ key }}: {{ value }}
  • + {% if loop.last %} + + {% endif %} {% endfor %}
    diff --git a/tests/Functional/Controller/TwigDocControllerTest.php b/tests/Functional/Controller/TwigDocControllerTest.php index e3787a1..0158ae6 100644 --- a/tests/Functional/Controller/TwigDocControllerTest.php +++ b/tests/Functional/Controller/TwigDocControllerTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use Qossmic\TwigDocBundle\Component\ComponentItemFactory; +use Qossmic\TwigDocBundle\Component\ComponentItemList; use Qossmic\TwigDocBundle\Controller\TwigDocController; use Qossmic\TwigDocBundle\Service\CategoryService; use Qossmic\TwigDocBundle\Service\ComponentService; @@ -18,6 +19,7 @@ #[UsesClass(CategoryService::class)] #[CoversClass(ComponentService::class)] #[UsesClass(TwigDocExtension::class)] +#[UsesClass(ComponentItemList::class)] class TwigDocControllerTest extends WebTestCase { private KernelBrowser $client; diff --git a/tests/Functional/Service/ComponentServiceTest.php b/tests/Functional/Service/ComponentServiceTest.php index 316d99a..44c6d4b 100644 --- a/tests/Functional/Service/ComponentServiceTest.php +++ b/tests/Functional/Service/ComponentServiceTest.php @@ -8,25 +8,26 @@ use Qossmic\TwigDocBundle\Component\ComponentInvalid; use Qossmic\TwigDocBundle\Component\ComponentItem; use Qossmic\TwigDocBundle\Component\ComponentItemFactory; +use Qossmic\TwigDocBundle\Component\ComponentItemList; use Qossmic\TwigDocBundle\Service\CategoryService; use Qossmic\TwigDocBundle\Service\ComponentService; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Contracts\Cache\CacheInterface; #[CoversClass(ComponentService::class)] #[UsesClass(ComponentItemFactory::class)] #[UsesClass(CategoryService::class)] +#[UsesClass(ComponentItemList::class)] class ComponentServiceTest extends KernelTestCase { #[DataProvider('getFilterTestCases')] - public function testFilter(string $query, string $type, array $expectedCounts): void + public function testFilter(string $query, string $type, int $expectedCount): void { $service = static::getContainer()->get(ComponentService::class); $components = $service->filter($query, $type); - foreach ($expectedCounts as $category => $count) { - static::assertCount($count, $components[$category]); - } + static::assertCount($expectedCount, $components); } public function testGetComponent(): void @@ -48,15 +49,6 @@ public function testGetComponentsByCategory(): void static::assertCount(4, $result); } - public function testGetCategories() - { - $service = static::getContainer()->get(ComponentService::class); - - $categories = $service->getCategories(); - - static::assertCount(1, $categories); - } - public function testGetInvalidComponents() { $service = static::getContainer()->get(ComponentService::class); @@ -75,7 +67,9 @@ public function testParsePerformance(): void $start = microtime(true); - new ComponentService($factory, $this->getLargeConfig()); + $service = new ComponentService($factory, $this->getLargeConfig(), static::getContainer()->get(CacheInterface::class)); + + $service->getComponents(); $elapsedTime = microtime(true) - $start; @@ -87,47 +81,37 @@ public static function getFilterTestCases(): iterable yield 'name' => [ 'query' => 'button', 'type' => 'name', - 'expectedCounts' => [ - 'MainCategory' => 3, - ], + 'expectedCount' => 3, ]; yield 'category' => [ 'query' => 'MainCategory', 'type' => 'category', - 'expectedCounts' => [ - 'MainCategory' => 4, - ], + 'expectedCount' => 4, ]; yield 'sub_category' => [ 'query' => 'SubCategory2', 'type' => 'sub_category', - 'expectedCounts' => [ - 'MainCategory' => 1, - ], + 'expectedCount' => 1, ]; yield 'tags' => [ 'query' => 'snippet', 'type' => 'tags', - 'expectedCounts' => [ - 'MainCategory' => 1, - ], + 'expectedCount' => 1, ]; yield 'any' => [ 'query' => 'action', 'type' => '', - 'expectedCounts' => [ - 'MainCategory' => 1, - ], + 'expectedCount' => 1, ]; } private function getLargeConfig(): array { - return array_fill(0, 5000, [ + return array_fill(0, 2500, [ 'name' => 'component', 'title' => 'title', 'description' => 'long long text long long text long long text long long text long long text long long text long long text ', diff --git a/tests/Functional/Twig/TwigDocExtensionTest.php b/tests/Functional/Twig/TwigDocExtensionTest.php index 9880a3c..cd17cd0 100644 --- a/tests/Functional/Twig/TwigDocExtensionTest.php +++ b/tests/Functional/Twig/TwigDocExtensionTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use Qossmic\TwigDocBundle\Component\ComponentItemFactory; +use Qossmic\TwigDocBundle\Component\ComponentItemList; use Qossmic\TwigDocBundle\Service\CategoryService; use Qossmic\TwigDocBundle\Service\ComponentService; use Qossmic\TwigDocBundle\Twig\TwigDocExtension; @@ -15,6 +16,7 @@ #[UsesClass(ComponentItemFactory::class)] #[UsesClass(CategoryService::class)] #[UsesClass(ComponentService::class)] +#[UsesClass(ComponentItemList::class)] class TwigDocExtensionTest extends KernelTestCase { public function testGetFunctions(): void diff --git a/tests/Unit/Component/ComponentItemListTest.php b/tests/Unit/Component/ComponentItemListTest.php new file mode 100644 index 0000000..dce80e4 --- /dev/null +++ b/tests/Unit/Component/ComponentItemListTest.php @@ -0,0 +1,207 @@ +setName('name'.$i) + ->setCategory((new ComponentCategory())->setName('category')) + ->setTitle('title') + ->setDescription('description'); + $items[] = $item; + } + + $list = new ComponentItemList($items); + + $paginated = $list->paginate(90, 10); + + static::assertCount(10, $paginated); + + $paginated = $list->paginate(95, 10); + + static::assertCount(5, $paginated); + } + + #[DataProvider('getSortTestCases')] + public function testSort(string $field, string $direction, string $expectedPropertyValue): void + { + $items = []; + for ($i = 0; $i < 100; ++$i) { + $item = new ComponentItem(); + $item->setName('name'.$i) + ->setCategory((new ComponentCategory())->setName('category'.$i)) + ->setTitle('title'.$i) + ->setDescription('description'.$i); + $items[] = $item; + } + + $list = new ComponentItemList($items); + + $list->sort($field, $direction); + + $items = $list->paginate(); + + static::assertEquals($expectedPropertyValue, \call_user_func([$items[0], sprintf('get%s', ucfirst($field))])); + } + + #[DataProvider('getFilterTestCases')] + public function testFilter(array $components, string $query, ?string $type, int $expectedCount): void + { + $list = new ComponentItemList($components); + + $filtered = $list->filter($query, $type); + + static::assertCount($expectedCount, $filtered); + } + + #[DataProvider('getInvalidFields')] + public function testSortThrowsInvalidArgumentExceptionForNonSortableFields(string $field): void + { + static::expectException(\InvalidArgumentException::class); + + $list = new ComponentItemList([]); + + $list->sort($field); + } + + public static function getSortTestCases(): iterable + { + yield 'name ASC' => [ + 'name', + ComponentItemList::SORT_ASC, + 'name0', + ]; + + yield 'name DESC' => [ + 'name', + ComponentItemList::SORT_DESC, + 'name99', + ]; + + yield 'category ASC' => [ + 'category', + ComponentItemList::SORT_ASC, + 'category0', + ]; + + yield 'category DESC' => [ + 'category', + ComponentItemList::SORT_DESC, + 'category99', + ]; + } + + public static function getInvalidFields(): array + { + return [ + ['description'], + ['tags'], + ['parameters'], + ['variations'], + ]; + } + + public static function getFilterTestCases(): iterable + { + yield 'case insensitive, like match by name' => [ + 'components' => [ + (new ComponentItem()) + ->setName('Component'), + (new ComponentItem()) + ->setName('button'), + (new ComponentItem()) + ->setName('ShinyComponent'), + ], + 'query' => 'component', + 'type' => 'name', + 'expectedCount' => 2, + ]; + + yield 'case insensitive, exact match by category' => [ + 'components' => [ + (new ComponentItem()) + ->setCategory((new ComponentCategory())->setName('Category')), + (new ComponentItem()) + ->setCategory((new ComponentCategory())->setName('Category2')), + ], + 'query' => 'category', + 'type' => 'category', + 'expectedCount' => 1, + ]; + + yield 'case insensitive, exact match by sub-category' => [ + 'components' => [ + (new ComponentItem()) + ->setCategory( + (new ComponentCategory())->setName('Category') + ->setParent((new ComponentCategory())->setName('ParentCategory')) + ), + (new ComponentItem()) + ->setCategory((new ComponentCategory())->setName('Category')), + ], + 'query' => 'category', + 'type' => 'sub_category', + 'expectedCount' => 1, + ]; + + yield 'case insensitive, exact match by tags' => [ + 'components' => [ + (new ComponentItem()) + ->setName('cmp1') + ->setTags(['tag1', 'tag2', 'tag3']), + (new ComponentItem()) + ->setName('cmp2') + ->setTags(['tag1', 'tag2', 'tag3']), + (new ComponentItem()) + ->setName('cmp3') + ->setTags(['tag4', 'tag5', 'tag6']), + (new ComponentItem()) + ->setName('cmp4') + ->setTags(['tag7', 'tag8', 'tag9']), + ], + 'query' => 'tag1, Tag2, TAG3', + 'type' => 'tags', + 'expectedCount' => 2, + ]; + + yield 'search all fields' => [ + 'components' => [ + (new ComponentItem()) + ->setName('cmp1') + ->setTags(['tag1', 'tag2', 'tag3']) + ->setCategory( + (new ComponentCategory())->setName('Category') + ->setParent((new ComponentCategory())->setName('Anything')) + ), + (new ComponentItem()) + ->setName('cmpTag2') + ->setTags(['tag1', 'tag2', 'anything']) + ->setCategory((new ComponentCategory())->setName('Category')), + (new ComponentItem()) + ->setName('cmpAnything3') + ->setTags(['tag4', 'tag5', 'tag6']) + ->setCategory((new ComponentCategory())->setName('Category')), + (new ComponentItem()) + ->setName('cmp4') + ->setTags(['tag', 'tag8', 'tag9']) + ->setCategory((new ComponentCategory())->setName('Category')), + ], + 'query' => 'anything', + 'type' => null, + 'expectedCount' => 3, + ]; + } +}