From 1db1ee4cd8fc5c1e1a58a21ab31f1521fc2e6b82 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 4 Aug 2026 10:35:49 +0330 Subject: [PATCH] test(schedule): add tests for booking mode changes and resource strategy removal --- .../schedule/ScheduleSection.test.tsx | 79 ++++++++++ .../components/schedule/ScheduleSection.tsx | 141 ++++-------------- 2 files changed, 104 insertions(+), 116 deletions(-) create mode 100644 assets/admin/components/schedule/ScheduleSection.test.tsx diff --git a/assets/admin/components/schedule/ScheduleSection.test.tsx b/assets/admin/components/schedule/ScheduleSection.test.tsx new file mode 100644 index 00000000..928c9dff --- /dev/null +++ b/assets/admin/components/schedule/ScheduleSection.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen } 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 { status = 500; }, +})); + +import { api } from '../../lib/api'; +import { WeeklyScheduleTab } from './ScheduleSection'; +import type { AddressData } from './ScheduleSection'; + +const get = api.get as ReturnType; + +/** بدون حداقل یک مکان، تب اصلاً فرم را رندر نمی‌کند. */ +const ADDRESSES: AddressData[] = [{ + id: '1', uuid: 'addr-1', type: 'personal', clinic_id: null, clinic_name: null, + name: 'مطب', address: 'تهران', telephone: null, + map: { latitude: null, longitude: null }, city: null, province: null, +}]; + +/** پاسخ برنامهٔ هفتگی؛ فقط `meta` بین تست‌ها فرق می‌کند. */ +function mockSchedule(meta: Record, locked = false) { + get.mockImplementation((url?: string) => { + if (typeof url === 'string' && url.includes('weekly-schedule')) { + return Promise.resolve({ + success: true, + data: { + uuid: 'sch-1', + doctor_uuid: 'd1', + schedule: {}, + meta: { online_booking_enabled: true, booking_window_value: 3, booking_window_unit: 'month', buffer_minutes: 0, ...meta }, + booking_mode_locked: locked, + }, + }); + } + return Promise.resolve({ success: true, data: [] }); + }); +} + +beforeEach(() => get.mockReset()); + +describe('WeeklyScheduleTab — روش نوبت‌دهی', () => { + /** + * نوبت‌دهی منبع‌محور از تنظیمات پزشک برداشته شده: انتخابش برگشت‌ناپذیر بود و + * منابع مسیر نوبت‌دهی مستقل خودشان را دارند. + */ + it('گزینهٔ «نوبت‌دهی منبع‌محور» دیگر قابل انتخاب نیست', async () => { + mockSchedule({ booking_mode: 'slot' }); + renderWithProviders(); + + expect(await screen.findByText('نوبت‌دهی اسلاتی')).toBeInTheDocument(); + expect(screen.getByText('نوبت‌دهی سرویسی')).toBeInTheDocument(); + expect(screen.queryByText('نوبت‌دهی منبع‌محور')).toBeNull(); + // فیلدهای مخصوص همان حالت هم رفته‌اند. + expect(screen.queryByText('گام جستجوی وقت')).toBeNull(); + expect(screen.queryByText('ترتیب انتخاب منابع')).toBeNull(); + }); + + it('استراتژی‌ها و شرط‌های آمادگیِ منبع‌محور دیگر درخواست نمی‌شوند', async () => { + mockSchedule({ booking_mode: 'slot' }); + renderWithProviders(); + + await screen.findByText('نوبت‌دهی اسلاتی'); + const urls = get.mock.calls.map((c: unknown[]) => c[0]).filter((u): u is string => typeof u === 'string'); + expect(urls.some(u => u.includes('resource-strategies'))).toBe(false); + expect(urls.some(u => u.includes('/api/v1/service-items'))).toBe(false); + }); + + /** رکورد قدیمیِ منبع‌محور باید روشِ ثبت‌شده‌اش را بگوید، نه اینکه بی‌حالت دیده شود. */ + it('رکورد قدیمیِ منبع‌محور فقط به‌صورت خواندنی اعلام می‌شود', async () => { + mockSchedule({ booking_mode: 'resource' }, true); + renderWithProviders(); + + expect(await screen.findByText(/روش ثبت‌شدهٔ این پزشک/)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /نوبت‌دهی منبع‌محور/ })).toBeNull(); + }); +}); diff --git a/assets/admin/components/schedule/ScheduleSection.tsx b/assets/admin/components/schedule/ScheduleSection.tsx index 5df33c22..5f3ae2e9 100644 --- a/assets/admin/components/schedule/ScheduleSection.tsx +++ b/assets/admin/components/schedule/ScheduleSection.tsx @@ -60,10 +60,14 @@ interface BookingMeta { online_booking_enabled: boolean; booking_window_value: number; booking_window_unit: BookingWindowUnit; + /** + * `resource` دیگر قابل انتخاب نیست و فقط برای خواندنِ رکوردهای قدیمی در نوع می‌ماند + * — سرور همچنان همان مقدار را برمی‌گرداند. + */ booking_mode: 'slot' | 'service' | 'resource'; - /** گام جستجوی وقت در حالت منبع‌محور — دقیقه */ + /** فقط پس‌فرست می‌شود تا مقدارِ رکوردهای قدیمی در PATCH پاک نشود. */ step_minutes?: number; - /** ترتیب امتحان‌کردن منابع در حالت منبع‌محور */ + /** فقط پس‌فرست می‌شود تا مقدارِ رکوردهای قدیمی در PATCH پاک نشود. */ resource_strategy?: string; buffer_minutes: number; } @@ -80,6 +84,16 @@ const MODE_LABELS: Record = { resource: 'نوبت‌دهی منبع‌محور', }; +/** + * حالت‌های قابل انتخاب برای پزشک. `resource` عمداً اینجا نیست: نوبت‌دهی منبع‌محور از + * تنظیمات پزشک برداشته شده و مسیر خودش (صفحهٔ «نوبت منبع‌محور» و تقویم هر منبع) را + * دارد. انتخابش برگشت‌ناپذیر بود و محیط را قفل می‌کرد. + */ +const BOOKING_MODE_CARDS: ReadonlyArray = [ + ['slot', 'نوبت‌دهی اسلاتی', 'شما بازه‌های کاری و «مدت هر نوبت» را مشخص می‌کنید؛ سیستم بازه را به نوبت‌های هم‌اندازه تقسیم می‌کند. مناسب ویزیت‌های با زمان یکسان.'], + ['service', 'نوبت‌دهی سرویسی', 'مدت هر نوبت از «مدت سرویس» انتخاب‌شده تعیین می‌شود؛ سیستم نزدیک‌ترین زمان خالیِ کافی را پیشنهاد می‌دهد. مناسب خدمات با زمان متفاوت.'], +]; + const DEFAULT_BOOKING_META: BookingMeta = { online_booking_enabled: true, booking_window_value: 3, @@ -555,44 +569,6 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly // نوع نوبت‌دهی پس از اولین ثبت قفل می‌شود؛ confirmMode = دیالوگ هشدار قبل از ثبت اول. const [modeLocked, setModeLocked] = useState(false); - /** - * شرط‌های آمادگیِ حالت منبع‌محور. - * - * فقط وقتی پرسیده می‌شود که کاربر واقعاً همان حالت را انتخاب کرده باشد — دو - * درخواست اضافه روی هر بازکردن تنظیمات، برای چیزی که اکثر کلینیک‌ها انتخابش - * نمی‌کنند، هزینهٔ بی‌دلیل است. - */ - /** فهرست استراتژی‌ها از سرور می‌آید، نه از فهرستی که در فرانت تکرار شود. */ - const strategiesQ = useQuery({ - queryKey: ['resource-strategies'], - queryFn: () => - api.get>( - '/api/v1/appointment-settings/resource-strategies', - ), - enabled: meta.booking_mode === 'resource', - staleTime: 300_000, - }); - - const strategies = strategiesQ.data?.data ?? []; - - const readinessQ = useQuery({ - queryKey: ['resource-mode-readiness'], - queryFn: async () => { - const [resources, services] = await Promise.all([ - api.get>('/api/v1/resources'), - api.get>('/api/v1/service-items'), - ]); - - return { - hasResources: (resources.data ?? []).length > 0, - hasSegments: (services.data ?? []).some((s) => s.has_segments === true), - }; - }, - enabled: meta.booking_mode === 'resource' && !modeLocked, - staleTime: 30_000, - }); - - const resourceReadiness = readinessQ.data ?? { hasResources: false, hasSegments: false }; const [confirmMode, setConfirmMode] = useState(false); const scheduleQ = useQuery({ @@ -742,12 +718,16 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly روش نوبت‌دهی
+ {/* رکوردهای قدیمیِ منبع‌محور هنوز در پایگاه‌داده هستند؛ بدون این، کارتی + انتخاب‌شده دیده نمی‌شد و صفحه «بی‌حالت» به نظر می‌رسید. */} + {meta.booking_mode === 'resource' && ( +

+ + روش ثبت‌شدهٔ این پزشک «{MODE_LABELS.resource}» است؛ این روش دیگر قابل انتخاب نیست. +

+ )}
- {([ - ['slot', 'نوبت‌دهی اسلاتی', 'شما بازه‌های کاری و «مدت هر نوبت» را مشخص می‌کنید؛ سیستم بازه را به نوبت‌های هم‌اندازه تقسیم می‌کند. مناسب ویزیت‌های با زمان یکسان.'], - ['service', 'نوبت‌دهی سرویسی', 'مدت هر نوبت از «مدت سرویس» انتخاب‌شده تعیین می‌شود؛ سیستم نزدیک‌ترین زمان خالیِ کافی را پیشنهاد می‌دهد. مناسب خدمات با زمان متفاوت.'], - ['resource', 'نوبت‌دهی منبع‌محور', 'نوبت به چند بخش تقسیم می‌شود و هر بخش منابع خودش (اتاق، اپراتور، دستگاه) را می‌گیرد؛ وقت آزاد از تقاطع تقویم همان منابع می‌آید. مناسب کلینیک زیبایی و لیزر.'], - ] as const).map(([val, lbl, desc]) => { + {BOOKING_MODE_CARDS.map(([val, lbl, desc]) => { const selected = meta.booking_mode === val; return (