em->persist($resource); $this->em->flush(); return $resource; } /** @return array{0: DoctorAddress, 1: ResourceType} */ private function branch(): array { $user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); $clinic = new \App\Clinic\Entity\Clinic($user); $clinic->setName('کلینیک استراتژی ' . (++$this->idCursor)); $this->em->persist($clinic); $this->em->flush(); $address = DoctorAddress::forClinic($clinic->getId()); $address->setName('شعبه'); $this->em->persist($address); $type = new ResourceType('clinic', (int) $clinic->getId(), 'operator', 'اپراتور'); $this->em->persist($type); $this->em->flush(); return [$address, $type]; } private function namesOf(array $ordered): array { return array_map(static fn (ClinicResource $r): string => $r->getName(), $ordered); } public function testFirstAvailableOrdersByName(): void { [$address, $type] = $this->branch(); $candidates = [ $this->resource($address, $type, 'ج'), $this->resource($address, $type, 'الف'), $this->resource($address, $type, 'ب'), ]; $ordered = (new FirstAvailablePicker())->order( $candidates, new PickContext([], [], time()), ); self::assertSame(['الف', 'ب', 'ج'], $this->namesOf($ordered)); } /** ⭐ منبعی که کمترین وقت مرده جا می‌گذارد، اول امتحان می‌شود. */ public function testLeastGapPrefersTheTightestWindow(): void { [$address, $type] = $this->branch(); $tight = $this->resource($address, $type, 'دقیق'); $loose = $this->resource($address, $type, 'گشاد'); $start = 1_800_000_000; $end = $start + 3600; $free = [ // پنجرهٔ دقیقاً به اندازهٔ نوبت — هیچ وقتی هدر نمی‌رود. (int) $tight->getId() => [new TimeInterval($start, $end)], // پنجرهٔ چهارساعته — سه ساعت خالی می‌ماند. (int) $loose->getId() => [new TimeInterval($start - 3600, $end + 7200)], ]; $ordered = (new LeastGapPicker())->order( [$loose, $tight], new PickContext($free, [new TimeInterval($start, $end)], $start), ); self::assertSame(['دقیق', 'گشاد'], $this->namesOf($ordered)); } /** پخش بار عکسِ کمترین‌شکاف عمل می‌کند — و هر دو درست‌اند. */ public function testLeastLoadedPrefersTheEmptiestResource(): void { [$address, $type] = $this->branch(); $busy = $this->resource($address, $type, 'پرکار'); $free = $this->resource($address, $type, 'خلوت'); $start = 1_800_000_000; $windows = [ (int) $busy->getId() => [new TimeInterval($start, $start + 3600)], (int) $free->getId() => [new TimeInterval($start, $start + 6 * 3600)], ]; $ordered = (new LeastLoadedPicker())->order( [$busy, $free], new PickContext($windows, [new TimeInterval($start, $start + 1800)], $start), ); self::assertSame(['خلوت', 'پرکار'], $this->namesOf($ordered)); } /** ⭐ ترجیح است نه فیلتر: منبع ترجیحی جلو می‌آید، بقیه حذف نمی‌شوند. */ public function testSameAsPreviousLiftsThePreferredResourceWithoutDroppingOthers(): void { [$address, $type] = $this->branch(); $other = $this->resource($address, $type, 'اپراتور دیگر'); $preferred = $this->resource($address, $type, 'اپراتور جلسهٔ قبل'); $start = 1_800_000_000; $ordered = (new SameAsPreviousPicker())->order( [$other, $preferred], new PickContext([], [new TimeInterval($start, $start + 1800)], $start, [(int) $preferred->getId()]), ); self::assertSame(['اپراتور جلسهٔ قبل', 'اپراتور دیگر'], $this->namesOf($ordered)); self::assertCount(2, $ordered, 'هیچ کاندیدی نباید حذف شود'); } /** بدون ترجیح، همان ترتیب استراتژی پایه می‌ماند. */ public function testSameAsPreviousFallsBackWhenNothingIsPreferred(): void { [$address, $type] = $this->branch(); $a = $this->resource($address, $type, 'الف'); $b = $this->resource($address, $type, 'ب'); $start = 1_800_000_000; $ordered = (new SameAsPreviousPicker())->order( [$a, $b], new PickContext([], [new TimeInterval($start, $start + 1800)], $start), ); self::assertCount(2, $ordered); } // ── رجیستری ───────────────────────────────────────────────────────────── public function testRegistryResolvesEveryStrategy(): void { $registry = static::getContainer()->get(ResourcePickerRegistry::class); foreach (['first_available', 'least_gap', 'least_loaded', 'same_as_previous'] as $code) { self::assertTrue($registry->has($code), $code); self::assertSame($code, $registry->get($code)::code()); } self::assertCount(4, $registry->describe()); } /** ⭐ کلید ناشناخته نوبت‌دهی را نمی‌خواباند؛ به پیش‌فرض برمی‌گردد. */ public function testAnUnknownStrategyFallsBackToTheDefault(): void { $registry = static::getContainer()->get(ResourcePickerRegistry::class); self::assertSame('first_available', $registry->get('nonsense')::code()); self::assertSame('first_available', $registry->get(null)::code()); } /** ولی هنگام **ذخیره** رد می‌شود، وگرنه کاربر فکر می‌کند اعمال شده. */ public function testSavingAnUnknownStrategyIsRejected(): void { $owner = $this->createUser(['ROLE_DOCTOR']); $doctor = new \App\Doctor\Entity\Doctor($owner, 'دکتر استراتژی'); $this->em->persist($doctor); $this->em->flush(); $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, [ 'doctor_uuid' => $doctor->getUuid(), 'schedule' => [], 'meta' => ['booking_mode' => 'slot', 'resource_strategy' => 'nonsense'], ]); self::assertSame(422, $this->responseCode()); } public function testStrategyListIsExposedForThePanel(): void { $user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); $body = $this->authJson('GET', '/api/v1/appointment-settings/resource-strategies', $user); self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); self::assertCount(4, $body['data']); self::assertContains('least_gap', array_column($body['data'], 'code')); self::assertNotEmpty($body['data'][0]['label']); } }