From 022c0aaebf05f819cf52569635104b141d98e6e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 16 Dec 2025 14:03:18 +0100 Subject: [PATCH] Add support for parent > child notation in motive import. - Trimmed labels during motive import to ensure consistent formatting. - Introduced logic to parse and handle "parent > child" syntax in motive names. - Automatically creates and links parent motives if they don't exist. - Added test case to verify parent-child relationship creation and linking. --- .../Import/ImportMotivesFromDirectory.php | 45 +++++++++++- .../Import/ImportMotivesFromDirectoryTest.php | 68 +++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/src/Bundle/ChillTicketBundle/src/Service/Import/ImportMotivesFromDirectory.php b/src/Bundle/ChillTicketBundle/src/Service/Import/ImportMotivesFromDirectory.php index 2bd1178b0..a963d0f5a 100644 --- a/src/Bundle/ChillTicketBundle/src/Service/Import/ImportMotivesFromDirectory.php +++ b/src/Bundle/ChillTicketBundle/src/Service/Import/ImportMotivesFromDirectory.php @@ -54,19 +54,60 @@ final readonly class ImportMotivesFromDirectory throw new \RuntimeException(sprintf('Item %d: missing or invalid "label" (expected array).', $index)); } $labelArray = $item['label']; + // Trim all labels when they are strings + foreach ($labelArray as $k => $v) { + if (\is_string($v)) { + $labelArray[$k] = trim($v); + } + } + $labelForSearch = $labelArray[$lang] ?? null; if (!\is_string($labelForSearch) || '' === trim($labelForSearch)) { throw new \RuntimeException(sprintf('Item %d: missing label for language "%s".', $index, $lang)); } - $existing = $this->motiveRepository->findByLabel($labelForSearch, $lang); + // Support parent > child notation in the current language label + $parentName = null; + $childName = trim($labelForSearch); + if (false !== strpos($childName, '>')) { + [$parentName, $childName] = array_map('trim', explode('>', $childName, 2)); + } + + // Find or create the current motive (child or standalone) + $existing = $this->motiveRepository->findByLabel($childName, $lang); if (\count($existing) > 1) { - throw new \RuntimeException(sprintf('Item %d: multiple motives found with label "%s" for lang "%s".', $index, $labelForSearch, $lang)); + throw new \RuntimeException(sprintf('Item %d: multiple motives found with label "%s" for lang "%s".', $index, $childName, $lang)); } $motive = $existing[0] ?? new Motive(); + // Ensure the stored label for the current language is the child/standalone name (trimmed) + $labelArray[$lang] = $childName; $motive->setLabel($labelArray); + // If a parent is defined, ensure it exists and link it + if (null !== $parentName && '' !== $parentName) { + $parentCandidates = $this->motiveRepository->findByLabel($parentName, $lang); + if (\count($parentCandidates) > 1) { + throw new \RuntimeException(sprintf('Item %d: multiple parent motives found with label "%s" for lang "%s".', $index, $parentName, $lang)); + } + $parent = $parentCandidates[0] ?? null; + if (null === $parent) { + $parent = new Motive(); + $parentLabel = $labelArray; + $parentLabel[$lang] = $parentName; + // Make sure all labels are trimmed + foreach ($parentLabel as $k => $v) { + if (\is_string($v)) { + $parentLabel[$k] = trim($v); + } + } + $parent->setLabel($parentLabel); + $this->entityManager->persist($parent); + } + + $motive->setParent($parent); + } + // urgent: if true => YES, if explicitly false => NO, if absent => leave null if (array_key_exists('urgent', $item)) { $urgent = (bool) $item['urgent']; diff --git a/src/Bundle/ChillTicketBundle/tests/Service/Import/ImportMotivesFromDirectoryTest.php b/src/Bundle/ChillTicketBundle/tests/Service/Import/ImportMotivesFromDirectoryTest.php index b86d471a8..07e94f2be 100644 --- a/src/Bundle/ChillTicketBundle/tests/Service/Import/ImportMotivesFromDirectoryTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Service/Import/ImportMotivesFromDirectoryTest.php @@ -14,6 +14,7 @@ namespace Chill\TicketBundle\Tests\Service\Import; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Entity\StoredObjectVersion; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; +use Chill\TicketBundle\Entity\Motive; use Chill\TicketBundle\Repository\MotiveRepository; use Chill\TicketBundle\Service\Import\ImportMotivesFromDirectory; use Doctrine\ORM\EntityManagerInterface; @@ -92,4 +93,71 @@ YAML; // If we reached here, it's a successful smoke test $this->assertTrue(true); } + + public function testImportWithParentCreatesAndLinks(): void + { + // 1) Prepare temporary directory with a motives.yaml that declares a parent > child label + $tmpBase = sys_get_temp_dir().DIRECTORY_SEPARATOR.'chill_ticket_import_'.bin2hex(random_bytes(6)); + $this->assertTrue(mkdir($tmpBase, 0777, true)); + + $yaml = <<<'YAML' +- label: + fr: " Parent > Child " +YAML; + file_put_contents($tmpBase.DIRECTORY_SEPARATOR.'motives.yaml', $yaml); + + // 2) Create repository prophecy: both child and parent are initially missing + $repoProphecy = $this->prophesize(MotiveRepository::class); + $repoProphecy->findByLabel('Child', 'fr')->willReturn([])->shouldBeCalled(); + $repoProphecy->findByLabel('Parent', 'fr')->willReturn([])->shouldBeCalled(); + + // 3) Capture persisted motives to verify parent/child relation + $persistedMotives = []; + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->persist(Argument::type(Motive::class)) + ->will(function ($args) use (&$persistedMotives) { + $persistedMotives[] = $args[0]; + }) + ->shouldBeCalled(); + $emProphecy->flush()->shouldBeCalled(); + + // StoredObjectManager is unused in this scenario; provide a dummy mock + $somMock = $this->createMock(StoredObjectManagerInterface::class); + + // 4) Run the importer + $importer = new ImportMotivesFromDirectory( + $emProphecy->reveal(), + $somMock, + $repoProphecy->reveal(), + ); + $importer->import($tmpBase, 'fr'); + + // 5) Assertions: we should have at least two motives persisted (parent and child) + $this->assertGreaterThanOrEqual(2, \count($persistedMotives)); + + // Identify child and parent + $child = null; + $parent = null; + foreach ($persistedMotives as $m) { + if (!$m instanceof Motive) { + continue; + } + $label = $m->getLabel(); + $fr = $label['fr'] ?? null; + if ('Child' === $fr) { + $child = $m; + } elseif ('Parent' === $fr) { + $parent = $m; + } + } + + $this->assertInstanceOf(Motive::class, $child, 'Child motive must be created'); + $this->assertInstanceOf(Motive::class, $parent, 'Parent motive must be created'); + $this->assertSame($parent, $child->getParent(), 'Child must reference the created parent'); + $this->assertTrue($parent->isParent(), 'Parent must have at least one child'); + + // 6) Cleanup + @unlink($tmpBase.DIRECTORY_SEPARATOR.'motives.yaml'); + @rmdir($tmpBase); + } }