import { describe, it, expect, beforeEach, vi } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; 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 {}, })); vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); vi.mock('../hooks/usePermissions', () => ({ usePermissions: () => ({ can: () => true }) })); import { Routes, Route } from 'react-router-dom'; import { api } from '../lib/api'; import ResourceDetailPage from './ResourceDetailPage'; const get = api.get as ReturnType; const resource = { uuid: 'r1', name: 'لیزر دایود', address_uuid: 'a1', address_name: 'شعبهٔ مرکزی', type_uuid: 't1', type_code: 'device', type_name: 'دستگاه لیزر', capacity: 1, setup_minutes: 5, cleanup_minutes: 10, attributes: {}, subject_kind: null, subject_uuid: null, skills: [{ skill_uuid: 's1', skill_name: 'کار با لیزر', level: 4 }], categories: [{ uuid: 'c-hand', name: 'دست' }], active: true, created_at: 0, updated_at: 0, upcoming_appointments: 0, }; const emptyDays = (): Record => Object.fromEntries(Array.from({ length: 7 }, (_, d) => [String(d), [] as unknown[]])); function mockApi(days: Record = emptyDays(), availabilityDays: unknown[] = []) { get.mockImplementation((path: string) => { if (path === '/api/v1/resource/r1') return Promise.resolve({ success: true, data: resource }); if (path.endsWith('/calendar')) { return Promise.resolve({ success: true, data: { resource_uuid: 'r1', timezone: 'Asia/Tehran', defined: true, days }, }); } if (path.includes('/availability')) { return Promise.resolve({ success: true, data: { resource_uuid: 'r1', timezone: 'Asia/Tehran', days: availabilityDays }, }); } if (path.includes('/service-categories/tree')) { return Promise.resolve({ success: true, data: [{ uuid: 'c-hand', name: 'دست', sort_order: 0, active: true, children: [] }, { uuid: 'c-foot', name: 'پا', sort_order: 1, active: true, children: [] }], }); } return Promise.resolve({ success: true, data: [] }); }); } function renderPage(route = '/admin/resources/r1') { return renderWithProviders( } /> , { route }, ); } describe('ResourceDetailPage', () => { beforeEach(() => vi.clearAllMocks()); it('اطلاعات منبع را در تب پیش‌فرض نشان می‌دهد', async () => { mockApi(); renderPage(); await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument()); expect(screen.getByText('ظرفیت هم‌زمان')).toBeInTheDocument(); expect(screen.getByText('کار با لیزر · 4')).toBeInTheDocument(); }); it('تب از URL خوانده می‌شود تا بازگشت و رفرش همان نما را بدهد', async () => { mockApi(); renderPage('/admin/resources/r1?tab=hours'); await waitFor(() => expect(screen.getByText('شنبه')).toBeInTheDocument()); expect(screen.getByText('جمعه')).toBeInTheDocument(); expect(screen.getAllByText('بدون شیفت — این روز بسته است')).toHaveLength(7); }); it('شیفت ذخیره‌شده را به‌صورت ساعت نشان می‌دهد', async () => { const days = emptyDays(); days['0'] = [{ sequence: 0, start_minute: 540, end_minute: 1020, start_time: '09:00', end_time: '17:00', active: true }]; mockApi(days); renderPage('/admin/resources/r1?tab=hours'); await waitFor(() => expect(screen.getByDisplayValue('09:00')).toBeInTheDocument()); expect(screen.getByDisplayValue('17:00')).toBeInTheDocument(); }); /** * دلیلِ خالی بودن روز باید فارسی نشان داده شود؛ نشان دادن کلید خام سرور * («outside_branch_hours») به کاربر یعنی پیام بی‌معنا. */ it('دلیل خالی بودن روز را فارسی می‌کند', async () => { mockApi(emptyDays(), [ { date: 1785529800, day_of_week: 0, intervals: [], total_minutes: 0, reasons: ['national_holiday'] }, { date: 1785616200, day_of_week: 1, intervals: [], total_minutes: 0, reasons: ['no_shift'] }, { date: 1785702600, day_of_week: 2, intervals: [], total_minutes: 0, reasons: ['exception'] }, ]); renderPage('/admin/resources/r1?tab=exceptions'); await waitFor(() => expect(screen.getByText('تعطیل رسمی')).toBeInTheDocument()); expect(screen.getByText('شیفتی تعریف نشده')).toBeInTheDocument(); expect(screen.getByText('مرخصی یا سرویس')).toBeInTheDocument(); expect(screen.queryByText('no_shift')).not.toBeInTheDocument(); }); /** * وقتی هیچ روزی شیفت ندارد، ۱۴ سطرِ یکسان نویز است و با یک حالت خالی جمع می‌شود. * شرط باید تنگ بماند: تست بالا ثابت می‌کند دلیلِ غیر از `no_shift` سطرها را نگه می‌دارد. */ it('پیش‌نمایشِ کاملاً تعریف‌نشده را در یک حالت خالی جمع می‌کند', async () => { mockApi(emptyDays(), [ { date: 1785529800, day_of_week: 0, intervals: [], total_minutes: 0, reasons: ['no_shift'] }, { date: 1785616200, day_of_week: 1, intervals: [], total_minutes: 0, reasons: ['no_shift'] }, ]); renderPage('/admin/resources/r1?tab=exceptions'); await waitFor(() => expect(screen.getByText(/در دو هفتهٔ آینده هیچ ساعتی باز نیست/)).toBeInTheDocument()); expect(screen.queryByText('شیفتی تعریف نشده')).not.toBeInTheDocument(); }); /** نوار ذخیره فقط وقتی ظاهر می‌شود که چیزی تغییر کرده باشد — خودش نشانهٔ dirty است. */ it('نوار ذخیره تا وقتی تغییری نباشد نمی‌آید', async () => { const user = userEvent.setup(); mockApi(); renderPage('/admin/resources/r1?tab=hours'); await waitFor(() => expect(screen.getByText('شنبه')).toBeInTheDocument()); expect(screen.queryByRole('button', { name: 'ذخیرهٔ شیفت‌ها' })).not.toBeInTheDocument(); await user.click(screen.getAllByRole('button', { name: 'شیفت' })[0]); expect(await screen.findByText('تغییرات شیفت ذخیره نشده است')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'ذخیرهٔ شیفت‌ها' })).toBeInTheDocument(); }); /** «بازگرداندن» باید به نسخهٔ سرور برگردد، نه صرفاً آخرین ویرایش را لغو کند. */ it('بازگرداندن، draft را به نسخهٔ سرور برمی‌گرداند', async () => { const user = userEvent.setup(); mockApi(); renderPage('/admin/resources/r1?tab=hours'); await waitFor(() => expect(screen.getByText('شنبه')).toBeInTheDocument()); await user.click(screen.getAllByRole('button', { name: 'شیفت' })[0]); await user.click(await screen.findByRole('button', { name: 'بازگرداندن' })); await waitFor(() => expect(screen.queryByText('تغییرات شیفت ذخیره نشده است')).not.toBeInTheDocument()); expect(screen.getAllByText('بدون شیفت — این روز بسته است')).toHaveLength(7); }); /** * دو شیفتِ متداخل در یک روز نباید ذخیره شود. سرور هم همین را رد می‌کند، ولی خطا * باید حین ویرایش دیده شود نه بعد از رفت‌وبرگشت شبکه. */ it('شیفت‌های متداخل را رد می‌کند و ذخیره را می‌بندد', async () => { const days = emptyDays(); days['0'] = [ { sequence: 0, start_minute: 540, end_minute: 780, start_time: '09:00', end_time: '13:00', active: true }, { sequence: 1, start_minute: 1020, end_minute: 1140, start_time: '17:00', end_time: '19:00', active: true }, ]; const user = userEvent.setup(); mockApi(days); renderPage('/admin/resources/r1?tab=hours'); await waitFor(() => expect(screen.getByDisplayValue('17:00')).toBeInTheDocument()); expect(screen.queryByText(/تداخل دارند/)).not.toBeInTheDocument(); // ۱۷:۰۰–۱۹:۰۰ به ۱۲:۰۰ کشیده می‌شود و با ۰۹:۰۰–۱۳:۰۰ تداخل پیدا می‌کند. await user.clear(screen.getByLabelText('ساعت شروع شیفت ۲ روز شنبه')); await user.type(screen.getByLabelText('ساعت شروع شیفت ۲ روز شنبه'), '12:00'); // پیام دو جا می‌آید و باید بیاید: کنار خودِ روز، و در نوار ذخیره که دلیل قفل را می‌گوید. expect(await screen.findAllByText(/شیفت‌های روز شنبه با هم تداخل دارند/)).toHaveLength(2); expect(screen.getByRole('button', { name: 'ذخیرهٔ شیفت‌ها' })).toBeDisabled(); }); /** بازهٔ بدون تداخل نباید قربانی اعتبارسنجی شود — مرز چسبیده مجاز است. */ it('شیفت‌های چسبیده اما بدون هم‌پوشانی را می‌پذیرد', async () => { const days = emptyDays(); days['0'] = [ { sequence: 0, start_minute: 540, end_minute: 780, start_time: '09:00', end_time: '13:00', active: true }, { sequence: 1, start_minute: 780, end_minute: 1020, start_time: '13:00', end_time: '17:00', active: true }, ]; const user = userEvent.setup(); mockApi(days); renderPage('/admin/resources/r1?tab=hours'); await waitFor(() => expect(screen.getByDisplayValue('09:00')).toBeInTheDocument()); expect(screen.queryByText(/تداخل دارند/)).not.toBeInTheDocument(); await user.click(screen.getAllByRole('button', { name: 'شیفت' })[1]); expect(await screen.findByRole('button', { name: 'ذخیرهٔ شیفت‌ها' })).toBeEnabled(); }); /** آیکون کپی گمراه‌کننده بود و حذف شد. */ it('دکمهٔ کپی شیفت ندارد', async () => { const days = emptyDays(); days['0'] = [{ sequence: 0, start_minute: 540, end_minute: 1020, start_time: '09:00', end_time: '17:00', active: true }]; mockApi(days); renderPage('/admin/resources/r1?tab=hours'); await waitFor(() => expect(screen.getByDisplayValue('09:00')).toBeInTheDocument()); expect(screen.queryByRole('button', { name: /کپی/ })).not.toBeInTheDocument(); }); /** پیش‌نمایش نباید «وقت قابل رزرو» خوانده شود — نوبت‌ها هنوز کسر نشده‌اند. */ it('پیش‌نمایش را خام معرفی می‌کند', async () => { mockApi(); renderPage('/admin/resources/r1?tab=exceptions'); await waitFor(() => expect(screen.getByText(/نوبت‌های ثبت‌شده هنوز از آن کسر نشده‌اند/)).toBeInTheDocument()); }); it('تب دسته‌بندی فقط انتخاب می‌دهد، نه ساخت', async () => { mockApi(); const user = userEvent.setup(); renderPage(); await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument()); await user.click(screen.getByRole('button', { name: 'دسته‌بندی‌ها' })); await waitFor(() => expect(screen.getByText(/فقط انتخاب می‌شود/)).toBeInTheDocument()); expect(screen.queryByRole('button', { name: /افزودن دسته‌بندی جدید/ })).not.toBeInTheDocument(); expect(screen.getByText('تنظیمات ← دسته‌بندی‌ها')).toHaveAttribute('href', '/admin/service-categories'); }); });