Files
clinicpro/assets/admin/components/appointments/ResourceTimeline.test.tsx
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

133 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '../../test/utils';
import ResourceTimeline from './ResourceTimeline';
import type { ResourceTimelineLane } from '../../hooks/useResourceTimeline';
/** نیمه‌شبِ یک روز ثابت، تا موقعیت بلوک‌ها به ساعت اجرای تست وابسته نباشد. */
const DAY_START = Math.floor(new Date(2026, 7, 5, 0, 0, 0).getTime() / 1000);
const at = (hour: number, minute = 0) => DAY_START + hour * 3600 + minute * 60;
function lane(overrides: Partial<ResourceTimelineLane> = {}): ResourceTimelineLane {
return {
uuid: 'r-1',
name: 'لیزر دایود',
type_name: 'دستگاه لیزر',
address_name: 'شعبهٔ مرکزی',
capacity: 1,
shifts: [{ start_minute: 9 * 60, end_minute: 17 * 60 }],
shift_minutes: 480,
busy_minutes: 60,
free_minutes: 420,
items: [
{
uuid: 'o-1',
starts_at: at(10),
ends_at: at(11),
status: 'booked',
segment_name: 'لیزر',
appointment_id: 47,
patient_name: 'زهرا احمدی',
appointment_uuid: 'appt-1',
appointment_status: 'confirmed',
},
],
...overrides,
};
}
describe('ResourceTimeline', () => {
it('یک ردیف به‌ازای هر منبع با نام بیمار و بخش نشان می‌دهد', () => {
renderWithProviders(
<ResourceTimeline lanes={[lane()]} dayStart={DAY_START} loading={false} error={null} />,
);
expect(screen.getByText('لیزر دایود')).toBeInTheDocument();
expect(screen.getByText('دستگاه لیزر')).toBeInTheDocument();
expect(screen.getByText('زهرا احمدی · لیزر')).toBeInTheDocument();
});
it('بلوک به جزئیات همان نوبت لینک می‌دهد', () => {
renderWithProviders(
<ResourceTimeline lanes={[lane()]} dayStart={DAY_START} loading={false} error={null} />,
);
expect(screen.getByText('زهرا احمدی · لیزر').closest('a'))
.toHaveAttribute('href', '/admin/appointments/appt-1');
});
/**
* یک نوبت می‌تواند هم‌زمان اتاق و دستگاه را بگیرد؛ همین است که ظرفیت را تمام می‌کند و
* نمای پزشک‌محور نشانش نمی‌دهد.
*/
it('یک نوبت روی چند منبع، روی هر ردیف دیده می‌شود', () => {
const room = lane({
uuid: 'r-2',
name: 'اتاق لیزر ۱',
type_name: 'اتاق',
items: [{ ...lane().items[0], uuid: 'o-2', segment_name: 'بی‌حسی موضعی' }],
});
renderWithProviders(
<ResourceTimeline lanes={[lane(), room]} dayStart={DAY_START} loading={false} error={null} />,
);
expect(screen.getByText('زهرا احمدی · لیزر')).toBeInTheDocument();
expect(screen.getByText('زهرا احمدی · بی‌حسی موضعی')).toBeInTheDocument();
});
it('روزِ بدون شیفت و بدون نوبت را صریح می‌گوید', () => {
renderWithProviders(
<ResourceTimeline lanes={[lane({ shifts: [], items: [] })]} dayStart={DAY_START} loading={false} error={null} />,
);
expect(screen.getByText('این روز شیفتی ندارد')).toBeInTheDocument();
});
it('بدون هیچ منبعی به صفحهٔ تعریف منبع راه می‌دهد', () => {
renderWithProviders(
<ResourceTimeline lanes={[]} dayStart={DAY_START} loading={false} error={null} />,
);
expect(screen.getByText('تنظیمات ← منابع').closest('a')).toHaveAttribute('href', '/admin/resources');
});
it('خطای سرور را نشان می‌دهد، نه تایم‌لاین خالی', () => {
renderWithProviders(
<ResourceTimeline lanes={[]} dayStart={DAY_START} loading={false} error="خطای داخلی سرور" />,
);
expect(screen.getByText('خطای داخلی سرور')).toBeInTheDocument();
});
/** ظرفیت و وقت آزاد باید روی خودِ ردیف باشد، نه حدسِ کاربر از روی بلوک‌ها. */
it('وقت آزاد هر ردیف را نشان می‌دهد', () => {
renderWithProviders(
<ResourceTimeline lanes={[lane()]} dayStart={DAY_START} loading={false} error={null} />,
);
expect(screen.getByText('۴۲۰ دقیقه آزاد')).toBeInTheDocument();
});
it('ردیفِ پرشده «تکمیل» می‌گوید، نه صفر دقیقه', () => {
renderWithProviders(
<ResourceTimeline
lanes={[lane({ busy_minutes: 480, free_minutes: 0 })]}
dayStart={DAY_START}
loading={false}
error={null}
/>,
);
expect(screen.getByText('تکمیل')).toBeInTheDocument();
});
it('ظرفیت بیش از یک را کنار نوع منبع می‌آورد', () => {
renderWithProviders(
<ResourceTimeline lanes={[lane({ capacity: 3 })]} dayStart={DAY_START} loading={false} error={null} />,
);
expect(screen.getByText(/ظرفیت ۳/)).toBeInTheDocument();
});
});