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.
This commit is contained in:
2025-12-16 14:03:18 +01:00
parent 766a9292e0
commit 022c0aaebf
2 changed files with 111 additions and 2 deletions

View File

@@ -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'];

View File

@@ -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);
}
}