Strategies (task 06 debt, task 12 dependency) - ResourcePicker orders candidates; it deliberately does not choose. Only the engine knows which resource actually fits this slot and which was already taken by another role, and a strategy that picked would have to duplicate both checks - Four implementations behind a tagged iterator: first_available (name order, the previous behaviour and still the default because it is predictable), least_gap, least_loaded, same_as_previous - least_gap and least_loaded are deliberate opposites and both are correct; choosing between them is a business decision, so it lives in settings - same_as_previous lifts a course's preferred resource to the front and keeps everyone else behind it. A preference, not a filter: forcing the same operator would make the patient wait two weeks, which is worse than a different operator - Availability accepts course_uuid to supply that preference, closing the dependency task 12 recorded against task 06 - An unknown strategy falls back at search time but is rejected at save time. Stale settings must not stop bookings; a user typing a wrong value must not believe it took effect Test suite flake createUser() retries on a mobile-number collision — db_test is never reset and holds tens of thousands of users, so the random draw does collide. The failed INSERT closes the EntityManager, and the retry asked the container for it again, which hands back the *same closed instance*. So the retry threw, and every later test in that process inherited a dead manager. That is the intermittent "EntityManager is closed" on an unrelated, always-different test that made roughly half of full runs red and never reproduced in a subset. Resetting the registry gives a live manager back. UserCollisionRetryTest pins it by closing the manager on purpose. Two consecutive full runs are green: 1334 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
218 lines
8.4 KiB
PHP
218 lines
8.4 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Appointment;
|
|
|
|
use App\Appointment\Availability\Picker\FirstAvailablePicker;
|
|
use App\Appointment\Availability\Picker\LeastGapPicker;
|
|
use App\Appointment\Availability\Picker\LeastLoadedPicker;
|
|
use App\Appointment\Availability\Picker\PickContext;
|
|
use App\Appointment\Availability\Picker\ResourcePickerRegistry;
|
|
use App\Appointment\Availability\Picker\SameAsPreviousPicker;
|
|
use App\Doctor\Entity\DoctorAddress;
|
|
use App\Resource\Entity\ClinicResource;
|
|
use App\Resource\Entity\ResourceType;
|
|
use App\Shared\Time\TimeInterval;
|
|
use App\Tests\ApiTestCase;
|
|
|
|
/**
|
|
* استراتژیهای ترتیب منابع — تسک ۰۶.
|
|
*
|
|
* استراتژی **مرتب میکند، انتخاب نمیکند**؛ پس هر تست فقط ترتیب خروجی را میسنجد و
|
|
* هیچکدام نباید کاندیدی را حذف کند.
|
|
*/
|
|
class ResourcePickerTest extends ApiTestCase
|
|
{
|
|
private int $idCursor = 0;
|
|
|
|
/** منبعی با شناسهٔ واقعی، چون استراتژیها با `getId()` کار میکنند. */
|
|
private function resource(DoctorAddress $address, ResourceType $type, string $name): ClinicResource
|
|
{
|
|
$resource = new ClinicResource($address, $type, $name);
|
|
$this->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']);
|
|
}
|
|
}
|