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