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>
133 lines
5.1 KiB
TypeScript
133 lines
5.1 KiB
TypeScript
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();
|
||
});
|
||
});
|