Two gaps against the spec. Resources could not be categorised at all — only services carried a catalog category — so "this device is for hands and feet" was unsayable. And CatalogCategory::$parent is a tree built for menu ordering: one parent per category. Laser areas overlap, so "hand" belongs under both "whole body" and "upper limb" at once, which a tree cannot express. Containment is therefore a separate directed acyclic graph (catalog_category_includes) sitting beside the display hierarchy, and resources join the existing clinic-wide categories through a many-to-many rather than growing a parallel list of their own. CategoryClosureResolver walks it transitively: whole body includes lower body includes foot, so whole body includes foot without anyone writing that pair down. The walk reads every edge of the environment in one query and traverses in memory — a query per level would tie round-trips to graph depth. The visited set doubles as the cycle guard, so even data that already contains a loop cannot hang the traversal, and assertNoCycle refuses to create one. Selection now rejects picking an area together with a category that contains it: "whole body laser" and "hand laser" in one appointment is a 422 with a Persian message naming both. This replaces hand-written incompatible_with pairs for the area case — defined once on the category instead of per item pair — while that relation stays for incompatibilities that have nothing to do with areas. Nine tests, including the two-parents case a tree could not hold, the cycle refusal, the self-edge, and the empty-graph boundary. TenantSchemaCoverageTest caught the new edge entity as unclassified; it is registered as an aggregate child of the parent category, which is what the constructor already enforces. Suite 1286 green, phpstan at its 14-error baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
201 lines
7.7 KiB
PHP
201 lines
7.7 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\ClinicService;
|
|
|
|
use App\Clinic\Entity\Clinic;
|
|
use App\ClinicService\Entity\CatalogCategory;
|
|
use App\ClinicService\Entity\CatalogCategoryInclude;
|
|
use App\ClinicService\Entity\ItemGroup;
|
|
use App\ClinicService\Entity\ServiceItem;
|
|
use App\ClinicService\Entity\ServiceSection;
|
|
use App\ClinicService\Service\CategoryClosureResolver;
|
|
use App\ClinicService\Service\ServiceSelectionValidator;
|
|
use App\Shared\Exception\AppException;
|
|
use App\Tests\ApiTestCase;
|
|
|
|
/**
|
|
* دستهبندی سراسری کلینیک و رابطهٔ «شامل بودن».
|
|
*
|
|
* «تمام بدن» شامل «دست» و «پا» است — و این با `parent` درختی گفتنی نیست، چون «دست»
|
|
* میتواند همزمان زیر «تمام بدن» و زیر «اندام فوقانی» باشد. پس گراف است، نه درخت.
|
|
*/
|
|
class CategoryClosureTest extends ApiTestCase
|
|
{
|
|
private CategoryClosureResolver $closure;
|
|
private Clinic $clinic;
|
|
private ServiceSection $section;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->closure = new CategoryClosureResolver(
|
|
$this->em->getRepository(CatalogCategoryInclude::class),
|
|
);
|
|
|
|
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
|
$this->clinic = new Clinic($user);
|
|
$this->clinic->setName('کلینیک دستهبندی');
|
|
$this->em->persist($this->clinic);
|
|
$this->em->flush();
|
|
|
|
$this->section = new ServiceSection('clinic', (int) $this->clinic->getId(), 'لیزر');
|
|
$this->em->persist($this->section);
|
|
$this->em->flush();
|
|
}
|
|
|
|
private function category(string $name): CatalogCategory
|
|
{
|
|
$category = new CatalogCategory('clinic', (int) $this->clinic->getId(), $name);
|
|
$this->em->persist($category);
|
|
$this->em->flush();
|
|
|
|
return $category;
|
|
}
|
|
|
|
private function includes(CatalogCategory $parent, CatalogCategory $child): void
|
|
{
|
|
$this->closure->assertNoCycle($parent, $child);
|
|
$this->em->persist(new CatalogCategoryInclude($parent, $child));
|
|
$this->em->flush();
|
|
}
|
|
|
|
private function item(string $name, ?CatalogCategory $category = null): ServiceItem
|
|
{
|
|
$item = new ServiceItem($this->section, $name, 5_000_000);
|
|
$item->setDurationMinutes(30)->setCatalogCategory($category);
|
|
$this->em->persist($item);
|
|
$this->em->flush();
|
|
|
|
return $item;
|
|
}
|
|
|
|
// ── ✅ موفق ──────────────────────────────────────────────────────────────
|
|
|
|
public function testAWholeBodyCategoryIncludesTheAreasUnderIt(): void
|
|
{
|
|
$whole = $this->category('تمام بدن');
|
|
$hand = $this->category('دست');
|
|
$foot = $this->category('پا');
|
|
|
|
$this->includes($whole, $hand);
|
|
$this->includes($whole, $foot);
|
|
|
|
$descendants = $this->closure->descendants($whole);
|
|
|
|
self::assertContains((int) $hand->getId(), $descendants);
|
|
self::assertContains((int) $foot->getId(), $descendants);
|
|
self::assertCount(2, $descendants);
|
|
}
|
|
|
|
public function testContainmentIsTransitive(): void
|
|
{
|
|
$whole = $this->category('تمام بدن');
|
|
$lower = $this->category('نیمتنهٔ پایین');
|
|
$foot = $this->category('پا');
|
|
|
|
$this->includes($whole, $lower);
|
|
$this->includes($lower, $foot);
|
|
|
|
// بدون بستار گذرا، کلینیک مجبور بود همهٔ جفتها را دستی بنویسد.
|
|
self::assertContains((int) $foot->getId(), $this->closure->descendants($whole));
|
|
self::assertTrue($this->closure->overlaps($whole, $foot));
|
|
}
|
|
|
|
public function testACategoryCanSitUnderTwoParentsWhichATreeCouldNotExpress(): void
|
|
{
|
|
$whole = $this->category('تمام بدن');
|
|
$upper = $this->category('اندام فوقانی');
|
|
$hand = $this->category('دست');
|
|
|
|
$this->includes($whole, $hand);
|
|
$this->includes($upper, $hand);
|
|
|
|
self::assertContains((int) $hand->getId(), $this->closure->descendants($whole));
|
|
self::assertContains((int) $hand->getId(), $this->closure->descendants($upper));
|
|
}
|
|
|
|
// ── ❌ خطا ───────────────────────────────────────────────────────────────
|
|
|
|
public function testACycleIsRefused(): void
|
|
{
|
|
$whole = $this->category('تمام بدن');
|
|
$hand = $this->category('دست');
|
|
|
|
$this->includes($whole, $hand);
|
|
|
|
// «دست شامل تمام بدن» حلقه میبندد و پیمایش را تا سرریز استک میبرد.
|
|
$this->expectException(AppException::class);
|
|
$this->closure->assertNoCycle($hand, $whole);
|
|
}
|
|
|
|
public function testACategoryCannotIncludeItself(): void
|
|
{
|
|
$whole = $this->category('تمام بدن');
|
|
|
|
$this->expectException(\InvalidArgumentException::class);
|
|
new CatalogCategoryInclude($whole, $whole);
|
|
}
|
|
|
|
// ── ⚠️ مرزی ──────────────────────────────────────────────────────────────
|
|
|
|
public function testACategoryWithNoEdgesHasNoDescendants(): void
|
|
{
|
|
self::assertSame([], $this->closure->descendants($this->category('تکافتاده')));
|
|
}
|
|
|
|
// ── تعارض انتخاب ─────────────────────────────────────────────────────────
|
|
|
|
public function testSelectingAnAreaTogetherWithTheWholeBodyIsRejected(): void
|
|
{
|
|
$whole = $this->category('تمام بدن');
|
|
$hand = $this->category('دست');
|
|
$this->includes($whole, $hand);
|
|
|
|
$wholeItem = $this->item('لیزر تمام بدن', $whole);
|
|
$handItem = $this->item('لیزر دست', $hand);
|
|
|
|
$result = $this->validator()->validate([$wholeItem, $handItem], []);
|
|
|
|
self::assertFalse($result['valid']);
|
|
self::assertSame('category_overlap', $result['errors'][0]['code']);
|
|
self::assertStringContainsString('تمام بدن', $result['errors'][0]['message']);
|
|
self::assertStringContainsString('دست', $result['errors'][0]['message']);
|
|
}
|
|
|
|
public function testTwoUnrelatedAreasAreFine(): void
|
|
{
|
|
$hand = $this->category('دست');
|
|
$foot = $this->category('پا');
|
|
|
|
// هیچکدام دیگری را در بر نمیگیرد، پس با هم انتخاب میشوند.
|
|
$result = $this->validator()->validate(
|
|
[$this->item('لیزر دست', $hand), $this->item('لیزر پا', $foot)],
|
|
[],
|
|
);
|
|
|
|
self::assertTrue($result['valid'], json_encode($result['errors'], JSON_UNESCAPED_UNICODE));
|
|
}
|
|
|
|
public function testItemsWithoutACategoryAreLeftAlone(): void
|
|
{
|
|
$result = $this->validator()->validate(
|
|
[$this->item('مشاوره'), $this->item('ویزیت')],
|
|
[],
|
|
);
|
|
|
|
self::assertTrue($result['valid']);
|
|
}
|
|
|
|
private function validator(): ServiceSelectionValidator
|
|
{
|
|
return new ServiceSelectionValidator(
|
|
$this->em->getRepository(\App\ClinicService\Entity\ItemGroupMember::class),
|
|
$this->em->getRepository(\App\ClinicService\Entity\ServiceItemRelation::class),
|
|
$this->em->getRepository(\App\ClinicService\Entity\ServiceBranchOverride::class),
|
|
new \App\ClinicService\Service\DurationCalculator(),
|
|
$this->closure,
|
|
);
|
|
}
|
|
}
|