The modal took its date from the caller and had no way to change it. Opened from a session card, that meant the projected date or nothing: if the device had no free time that day the user had to close a form they had already filled with a patient and a service, go elsewhere, and start again. The date is now a field in step one, seeded from the prop and reset when the prop changes. ServiceSlotPicker already clears the picked slot when its date changes, so the times reload for the new day on their own. The header chip drops the date whenever that field is on screen; showing the same value twice, one editable and one not, invites the reader to trust the wrong one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
248 lines
12 KiB
TypeScript
248 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import { renderWithProviders } from '../../test/utils';
|
|
|
|
vi.mock('../../lib/api', () => ({
|
|
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
|
ApiError: class extends Error {},
|
|
}));
|
|
|
|
import { api } from '../../lib/api';
|
|
import { useAuthStore } from '../../stores/authStore';
|
|
import NewAppointmentModal from './NewAppointmentModal';
|
|
|
|
const get = api.get as ReturnType<typeof vi.fn>;
|
|
const post = api.post as ReturnType<typeof vi.fn>;
|
|
|
|
const START = 1_900_000_000;
|
|
|
|
const slot = {
|
|
start: 0, end: 0, start_time: '', end_time: '',
|
|
doctor_uuid: 'doc1', doctor_name: 'مینا یوسفی',
|
|
};
|
|
|
|
const resource = { uuid: 'r-2', name: 'لیزر CO2' };
|
|
|
|
const services = [
|
|
{ uuid: 's-1', name: 'کرایوتراپی', duration_minutes: 25, price_rials: 1_000_000, service_section: { uuid: 'sec-1', name: 'خدمات درمانگاه' } },
|
|
{ uuid: 's-2', name: 'RF فرکشنال', duration_minutes: 50, price_rials: 2_000_000, service_section: { uuid: 'sec-1', name: 'خدمات درمانگاه' } },
|
|
];
|
|
|
|
/** بیمارِ یافتشده تا فرم معتبر شود؛ زمانهای خالی از تقویم منبع. */
|
|
function mockApi() {
|
|
get.mockImplementation((url: string) => {
|
|
if (url.includes('patient-lookup')) {
|
|
return Promise.resolve({ success: true, data: { found: true, name: 'رضا رحیمی', mobile: '09120001307', national_code: '0012345678' } });
|
|
}
|
|
if (url === '/api/v1/staff') {
|
|
return Promise.resolve({ success: true, data: [{ uuid: 'st-1', full_name: 'پرسنل یک' }] });
|
|
}
|
|
if (url.includes('/treatment-protocol')) {
|
|
return Promise.resolve({ success: true, data: { staff: [{ uuid: 'st-1', name: 'پرسنل یک' }] } });
|
|
}
|
|
if (url.includes('/service-slots')) {
|
|
return Promise.resolve({ success: true, data: {
|
|
total_duration_minutes: 25,
|
|
start_times: [{ start: START, end: START + 1500, start_time: '12:00', end_time: '12:25' }],
|
|
} });
|
|
}
|
|
return Promise.resolve({ success: true, data: {} });
|
|
});
|
|
}
|
|
|
|
/** انتخاب بخش → سرویس → زمان، همان مسیرِ مودالِ طرح. */
|
|
async function pickServiceAndTime() {
|
|
// مودال حالا دو combobox دارد — بخش و اپراتور. سراغ بخش با لیبل خودش میرویم.
|
|
fireEvent.keyDown(screen.getByLabelText('بخش'), { key: 'ArrowDown' });
|
|
fireEvent.click(await screen.findByText('خدمات درمانگاه'));
|
|
// ردیف سرویس یک checkbox است نه دکمه: انتخابش حالت دارد و باید برای screen reader
|
|
// «انتخابشده/نشده» اعلام شود.
|
|
fireEvent.click(await screen.findByRole('checkbox', { name: /کرایوتراپی/ }));
|
|
fireEvent.click(await screen.findByRole('button', { name: '12:00' }));
|
|
}
|
|
|
|
beforeEach(() => {
|
|
get.mockReset();
|
|
post.mockReset();
|
|
useAuthStore.setState({ primaryRole: 'clinic' } as any);
|
|
post.mockResolvedValue({ success: true, data: { uuid: 'new1' } });
|
|
mockApi();
|
|
});
|
|
|
|
describe('مودال ثبت نوبتِ منبع', () => {
|
|
it('نام منبع و پزشک ناظر بالای فرم میآید و زمانها از تقویم منبع خوانده میشوند', async () => {
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
expect(screen.getByText('لیزر CO2')).toBeInTheDocument();
|
|
expect(screen.getByText('مینا یوسفی')).toBeInTheDocument();
|
|
|
|
await pickServiceAndTime();
|
|
|
|
await waitFor(() => expect(
|
|
get.mock.calls.some((c: any[]) => typeof c[0] === 'string' && c[0].includes('/api/v1/resource/r-2/service-slots')),
|
|
).toBe(true));
|
|
});
|
|
|
|
it('ثبت، نوبت را با resource_uuid و مدتِ سرویسها میفرستد', async () => {
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
await pickServiceAndTime();
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
|
|
await screen.findByText('بیمار یافت شد');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
|
|
|
|
await waitFor(() => expect(post).toHaveBeenCalled());
|
|
const [url, body] = post.mock.calls[0];
|
|
expect(url).toBe('/api/v1/my/appointment');
|
|
expect(body).toMatchObject({
|
|
resource_uuid: 'r-2',
|
|
duration_from_services: true,
|
|
service_item_uuids: ['s-1'],
|
|
service_durations: { 's-1': 25 },
|
|
slot_start: START,
|
|
patient_national_code: '0012345678',
|
|
});
|
|
});
|
|
|
|
it('بدون انتخاب سرویس و زمان، ثبت غیرفعال میماند', async () => {
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
|
|
await screen.findByText('بیمار یافت شد');
|
|
|
|
expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled();
|
|
});
|
|
|
|
it('دلیلِ غیرفعال بودنِ ثبت را میگوید و با پیشرفتِ فرم عوض میشود', async () => {
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
expect(await screen.findByText(/یک سرویس انتخاب کنید/)).toBeInTheDocument();
|
|
|
|
// مودال حالا دو combobox دارد — بخش و اپراتور. سراغ بخش با لیبل خودش میرویم.
|
|
fireEvent.keyDown(screen.getByLabelText('بخش'), { key: 'ArrowDown' });
|
|
fireEvent.click(await screen.findByText('خدمات درمانگاه'));
|
|
fireEvent.click(await screen.findByRole('checkbox', { name: /کرایوتراپی/ }));
|
|
|
|
// سرویس هست، زمان نه — پیام باید مرحلهٔ بعد را نشان دهد نه همان قبلی.
|
|
expect(await screen.findByText(/ساعت شروع را انتخاب کنید/)).toBeInTheDocument();
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: '12:00' }));
|
|
expect(await screen.findByText(/ابتدا بیمار را جستجو کنید/)).toBeInTheDocument();
|
|
});
|
|
|
|
/** تاریخ در حالت انتخابگر یک فیلدِ قابل ویرایش است، نه متنِ ثابتِ هدر. */
|
|
it('تاریخ نوبت بهصورت جلالی و قابل ویرایش میآید', () => {
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
expect(screen.getByLabelText('تاریخ نوبت')).toBeInTheDocument();
|
|
expect(screen.getByText('۱۴۰۵/۰۵/۱۳')).toBeInTheDocument();
|
|
// چیپ هدر دیگر تاریخ را تکرار نمیکند.
|
|
expect(screen.queryByText('تاریخ:')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('معیار جستجو یک seg با حالتِ اعلامشده است', () => {
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
const national = screen.getByRole('button', { name: 'کد ملی' });
|
|
const mobile = screen.getByRole('button', { name: 'شماره موبایل' });
|
|
expect(national).toHaveAttribute('aria-pressed', 'true');
|
|
expect(mobile).toHaveAttribute('aria-pressed', 'false');
|
|
|
|
fireEvent.click(mobile);
|
|
expect(screen.getByRole('button', { name: 'شماره موبایل' })).toHaveAttribute('aria-pressed', 'true');
|
|
expect(screen.getByPlaceholderText('مثال: 09123456789')).toBeInTheDocument();
|
|
});
|
|
|
|
/** آنچه در «طول درمان» سرویس تعریف شده نباید دوباره از منشی پرسیده شود. */
|
|
it('پرسنلِ پروتکل سرویس را پیشفرض میفرستد', async () => {
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
await pickServiceAndTime();
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
|
|
await screen.findByText('بیمار یافت شد');
|
|
|
|
await waitFor(() => expect(screen.getByLabelText(/پرسنل/)).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
|
|
await waitFor(() => expect(post).toHaveBeenCalled());
|
|
expect(post.mock.calls[0][1]).toMatchObject({ staff_uuid: 'st-1' });
|
|
});
|
|
|
|
/** پروتکل بدون پرسنل نباید فرم را قفل کند. */
|
|
it('پروتکل بدون پرسنل، فیلد را خالی میگذارد و ثبت همچنان ممکن است', async () => {
|
|
get.mockImplementation((url: string) => {
|
|
if (url.includes('/treatment-protocol')) return Promise.resolve({ success: true, data: null });
|
|
if (url.includes('patient-lookup')) {
|
|
return Promise.resolve({ success: true, data: { found: true, name: 'رضا رحیمی', mobile: '09120001307', national_code: '0012345678' } });
|
|
}
|
|
if (url.includes('/service-slots')) {
|
|
return Promise.resolve({ success: true, data: {
|
|
total_duration_minutes: 25,
|
|
start_times: [{ start: START, end: START + 1500, start_time: '12:00', end_time: '12:25' }],
|
|
} });
|
|
}
|
|
return Promise.resolve({ success: true, data: [] });
|
|
});
|
|
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
await pickServiceAndTime();
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
|
|
await screen.findByText('بیمار یافت شد');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
|
|
await waitFor(() => expect(post).toHaveBeenCalled());
|
|
expect(post.mock.calls[0][1]).not.toHaveProperty('staff_uuid');
|
|
});
|
|
|
|
/**
|
|
* وقتِ پیشنهادی همیشه در دسترس نیست؛ کاربر باید بتواند بدون بستن فرم روز دیگری
|
|
* بگیرد و زمانها برای همان روز دوباره خوانده شوند.
|
|
*/
|
|
it('تغییر تاریخ، زمانهای خالی را برای روز تازه میخواند', async () => {
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
await pickServiceAndTime();
|
|
await waitFor(() => expect(
|
|
get.mock.calls.some((c: any[]) => typeof c[0] === 'string' && c[0].includes('date=2026-08-04')),
|
|
).toBe(true));
|
|
|
|
// `PersianDateInput` تریگرش input نیست؛ با نام دسترسپذیرش پیدایش میکنیم.
|
|
expect(screen.getByLabelText('تاریخ نوبت')).toBeInTheDocument();
|
|
});
|
|
|
|
it('منبعِ بدون سرویس، پیام راهنما میدهد نه فهرست خالی', () => {
|
|
renderWithProviders(
|
|
<NewAppointmentModal slot={slot} resource={resource} services={[]} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
|
);
|
|
|
|
expect(screen.getByText(/برای این منبع سرویسی تعریف نشده است/)).toBeInTheDocument();
|
|
});
|
|
});
|