Files
clinicpro/tests/Resource/ResourceTimelineEndpointTest.php
T
hamedandClaude Opus 5 5e87bbc18b feat(appointments): resources share the doctors' timeline
Three views become two. The resource lanes were a separate tab, which meant
reading a doctor's free hour on one screen and the laser's on another and
matching them by eye — while in the resource-first model it is the device and
the room that decide whether that hour is really free. They now sit under the
same "زمانبندی" view, below the doctor's slots.

Each lane says how much of its shift is still free, and that number respects
capacity: a minute counts as busy only once the overlapping bookings reach
the resource's capacity, so a three-bed room with two appointments is still
open. Treating it otherwise would silently turn every multi-capacity resource
into a single-capacity one. ResourceFreeTimeCalculator does the sweep and
carries nine cases of its own.

only_bookable=1 keeps resources with no service offering out of the view;
they could only ever render an empty lane. On the seeded clinic that is five
resources down to two.

Two ruler defects the screenshot caught: hours rendered in Latin digits, and
the last label was half-clipped by the container so 21 read as 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 16:14:04 +03:30

200 lines
8.5 KiB
PHP

<?php
namespace App\Tests\Resource;
use App\Appointment\Availability\Entity\ResourceOccupancy;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceCalendar;
use App\Resource\Entity\ResourceType;
use App\Tests\ApiTestCase;
/**
* تایم‌لاین منابع در صفحهٔ نوبت‌ها: «کدام دستگاه/اتاق کِی گرفته است».
*
* ردیف‌ها از `resource_occupancy` می‌آیند نه از خودِ نوبت، چون بازهٔ اشغال شاملِ
* آماده‌سازی و تمیزکاری هم هست و همان چیزی است که ظرفیت را تمام می‌کند.
*/
class ResourceTimelineEndpointTest extends ApiTestCase
{
/** @return array{0: User, 1: ClinicResource} */
private function clinicWithResource(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک تایم‌لاین');
$this->em->persist($clinic);
$this->em->flush();
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$this->em->flush();
$type = new ResourceType('clinic', (int) $clinic->getId(), 'device', 'دستگاه لیزر');
$this->em->persist($type);
$this->em->flush();
$resource = new ClinicResource($address, $type, 'لیزر دایود');
$this->em->persist($resource);
$this->em->flush();
return [$user, $resource];
}
private function occupy(ClinicResource $resource, int $start, int $end, string $segment): void
{
$occupancy = new ResourceOccupancy($resource, $start, $end);
$occupancy->setSegmentName($segment);
$this->em->persist($occupancy);
$this->em->flush();
}
private function shift(ClinicResource $resource, int $day, int $from, int $to): void
{
$this->em->persist(new ResourceCalendar($resource, $day, $from, $to));
$this->em->flush();
}
/** ۰ = شنبه، همان قرارداد تقویم منبع. */
private function dayOfWeek(int $timestamp): int
{
return (int) (((int) date('w', $timestamp) + 1) % 7);
}
// ── ✅ موفق ──────────────────────────────────────────────────────────────
public function testTheDayShowsEachResourceWithItsOccupiedRanges(): void
{
[$user, $resource] = $this->clinicWithResource();
$midnight = (int) strtotime('today midnight');
$this->shift($resource, $this->dayOfWeek($midnight), 540, 1020);
$this->occupy($resource, $midnight + 10 * 3600, $midnight + 11 * 3600, 'لیزر');
$body = $this->authJson('GET', '/api/v1/resources/timeline?date=' . date('Y-m-d', $midnight), $user);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertSame($midnight, $body['data']['date']);
$row = $body['data']['resources'][0];
self::assertSame('لیزر دایود', $row['name']);
self::assertSame('دستگاه لیزر', $row['type_name']);
self::assertSame([['start_minute' => 540, 'end_minute' => 1020]], $row['shifts']);
self::assertCount(1, $row['items']);
self::assertSame('لیزر', $row['items'][0]['segment_name']);
self::assertSame($midnight + 10 * 3600, $row['items'][0]['starts_at']);
}
public function testOnlyTheShiftsOfThatWeekdayComeBack(): void
{
[$user, $resource] = $this->clinicWithResource();
$midnight = (int) strtotime('today midnight');
$today = $this->dayOfWeek($midnight);
$this->shift($resource, $today, 540, 1020);
// شیفت روز دیگر نباید در تایم‌لاین امروز ظاهر شود.
$this->shift($resource, ($today + 3) % 7, 60, 120);
$body = $this->authJson('GET', '/api/v1/resources/timeline?date=' . date('Y-m-d', $midnight), $user);
self::assertSame([['start_minute' => 540, 'end_minute' => 1020]], $body['data']['resources'][0]['shifts']);
}
// ── ⚠️ مرزی ─────────────────────────────────────────────────────────────
public function testARestDayReturnsTheResourceWithNothingOnIt(): void
{
[$user, $resource] = $this->clinicWithResource();
$midnight = (int) strtotime('today midnight');
$this->occupy($resource, $midnight - 5 * 86400, $midnight - 5 * 86400 + 3600, 'هفتهٔ پیش');
$body = $this->authJson('GET', '/api/v1/resources/timeline?date=' . date('Y-m-d', $midnight), $user);
// منبع می‌ماند تا ردیفش در تایم‌لاین دیده شود؛ فقط خالی است.
self::assertCount(1, $body['data']['resources']);
self::assertSame([], $body['data']['resources'][0]['items']);
self::assertSame([], $body['data']['resources'][0]['shifts']);
}
public function testWithoutADateItFallsBackToToday(): void
{
[$user] = $this->clinicWithResource();
$body = $this->authJson('GET', '/api/v1/resources/timeline', $user);
self::assertSame(200, $this->responseCode());
self::assertSame((int) strtotime('today midnight'), $body['data']['date']);
}
// ── ❌ خطا ───────────────────────────────────────────────────────────────
public function testAMalformedDateIsRefused(): void
{
[$user] = $this->clinicWithResource();
$this->authJson('GET', '/api/v1/resources/timeline?date=05-08-2026', $user);
self::assertSame(422, $this->responseCode());
}
public function testAnotherEnvironmentDoesNotSeeTheseResources(): void
{
[, $resource] = $this->clinicWithResource();
$midnight = (int) strtotime('today midnight');
$this->occupy($resource, $midnight + 3600, $midnight + 7200, 'لیزر');
[$stranger] = $this->clinicWithResource();
$body = $this->authJson('GET', '/api/v1/resources/timeline?date=' . date('Y-m-d', $midnight), $stranger);
// منبعِ خودش را می‌بیند، نه منبع کلینیک دیگر.
self::assertCount(1, $body['data']['resources']);
self::assertSame([], $body['data']['resources'][0]['items']);
}
// ── وقت آزاد و فیلتر «قابل رزرو» ────────────────────────────────────────
/** ردیف تایم‌لاین باید بگوید چقدر از شیفت هنوز جا دارد، نه فقط چه چیزی گرفته است. */
public function testTheLaneReportsHowMuchOfTheShiftIsStillFree(): void
{
[$user, $resource] = $this->clinicWithResource();
$midnight = (int) strtotime('today midnight');
$this->shift($resource, $this->dayOfWeek($midnight), 540, 1020); // ۸ ساعت
$this->occupy($resource, $midnight + 10 * 3600, $midnight + 11 * 3600, 'لیزر');
$row = $this->authJson('GET', '/api/v1/resources/timeline?date=' . date('Y-m-d', $midnight), $user)
['data']['resources'][0];
self::assertSame(480, $row['shift_minutes']);
self::assertSame(60, $row['busy_minutes']);
self::assertSame(420, $row['free_minutes']);
}
/** منبعی که به هیچ سرویسی وصل نیست، ردیفِ همیشه‌خالی در تایم‌لاین نمی‌سازد. */
public function testOnlyBookableHidesResourcesWithNoService(): void
{
[$user, $resource] = $this->clinicWithResource();
$midnight = (int) strtotime('today midnight');
$this->shift($resource, $this->dayOfWeek($midnight), 540, 1020);
$all = $this->authJson('GET', '/api/v1/resources/timeline?date=' . date('Y-m-d', $midnight), $user);
self::assertCount(1, $all['data']['resources']);
$bookable = $this->authJson(
'GET',
'/api/v1/resources/timeline?only_bookable=1&date=' . date('Y-m-d', $midnight),
$user,
);
self::assertSame([], $bookable['data']['resources'], 'بدون ResourceServiceOffering فعال، قابل رزرو نیست');
}
}