import { describe, it, expect, beforeEach, vi } from 'vitest'; import { waitFor } from '@testing-library/react'; import { renderHookWithClient } 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 {}, })); const drive = vi.fn(); const destroyHandlers: Array<() => void> = []; vi.mock('driver.js', () => ({ driver: vi.fn((config: { onDestroyed?: () => void }) => { if (config.onDestroyed) destroyHandlers.push(config.onDestroyed); return { drive }; }), })); vi.mock('driver.js/dist/driver.css', () => ({})); import { driver } from 'driver.js'; import { api } from '@/lib/api'; import { useTour } from '@/hooks/useTour'; import { useTourProgress } from '@/hooks/useTourProgress'; import { appointmentsTour } from '@/lib/tour/tours/appointments'; const get = api.get as ReturnType; const post = api.post as ReturnType; const driverMock = driver as unknown as ReturnType; function mountAppointmentsAnchors() { document.body.innerHTML = appointmentsTour.steps .map((s) => `
`) .join(''); } beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); document.body.innerHTML = ''; destroyHandlers.length = 0; get.mockReset(); post.mockReset(); drive.mockReset(); driverMock.mockClear(); }); describe('useTourProgress', () => { it('نقشهٔ دیده‌شده‌ها را از سرور می‌خواند', async () => { get.mockResolvedValue({ data: { seen: { appointments: 2 } } }); const { result } = renderHookWithClient(() => useTourProgress()); await waitFor(() => expect(result.current.isReady).toBe(true)); expect(result.current.isSeen('appointments', 2)).toBe(true); expect(result.current.isSeen('appointments', 3)).toBe(false); expect(result.current.isSeen('patients', 1)).toBe(false); }); it('ثبت دیده‌شدن، کش را بدون درخواست دوباره به‌روز می‌کند', async () => { get.mockResolvedValue({ data: { seen: {} } }); post.mockResolvedValue({ data: { tourId: 'appointments', version: 1 } }); const { result } = renderHookWithClient(() => useTourProgress()); await waitFor(() => expect(result.current.isReady).toBe(true)); result.current.markSeen('appointments', 1); await waitFor(() => expect(result.current.isSeen('appointments', 1)).toBe(true)); expect(post).toHaveBeenCalledWith('/api/v1/my/tours/appointments/seen', { version: 1 }); expect(get).toHaveBeenCalledTimes(1); }); }); describe('useTour', () => { it('تور دیده‌نشده را بعد از آماده شدن صفحه خودکار اجرا می‌کند', async () => { get.mockResolvedValue({ data: { seen: {} } }); mountAppointmentsAnchors(); renderHookWithClient(() => useTour('appointments', { ready: true })); await waitFor(() => expect(driverMock).toHaveBeenCalled()); await vi.advanceTimersByTimeAsync(400); expect(drive).toHaveBeenCalledTimes(1); expect(driverMock.mock.calls[0][0].steps).toHaveLength(appointmentsTour.steps.length); }); it('شمارندهٔ استپ‌ها با ارقام فارسی است', async () => { get.mockResolvedValue({ data: { seen: {} } }); mountAppointmentsAnchors(); renderHookWithClient(() => useTour('appointments', { ready: true })); await vi.advanceTimersByTimeAsync(400); await waitFor(() => expect(drive).toHaveBeenCalled()); const steps = driverMock.mock.calls[0][0].steps; expect(steps[0].popover.progressText).toBe('۱ از ۸'); expect(steps[7].popover.progressText).toBe('۸ از ۸'); }); it('رندرهای پیاپی قبل از شلیک تایمر، اجرای خودکار را لغو نمی‌کنند', async () => { get.mockResolvedValue({ data: { seen: {} } }); mountAppointmentsAnchors(); const { rerender } = renderHookWithClient(() => useTour('appointments', { ready: true })); // هر رندر تابع start تازه‌ای می‌سازد؛ اگر وابستگیِ effect باشد، cleanup // تایمر را پاک می‌کند و تور هرگز اجرا نمی‌شود. for (let i = 0; i < 5; i++) rerender(); await vi.advanceTimersByTimeAsync(400); await waitFor(() => expect(drive).toHaveBeenCalledTimes(1)); }); it('استپِ بدون المان را حذف می‌کند', async () => { get.mockResolvedValue({ data: { seen: {} } }); document.body.innerHTML = '
'; renderHookWithClient(() => useTour('appointments', { ready: true })); await vi.advanceTimersByTimeAsync(400); await waitFor(() => expect(drive).toHaveBeenCalled()); expect(driverMock.mock.calls[0][0].steps).toHaveLength(1); }); it('تور دیده‌شده خودکار اجرا نمی‌شود', async () => { get.mockResolvedValue({ data: { seen: { appointments: appointmentsTour.version } } }); mountAppointmentsAnchors(); const { result } = renderHookWithClient(() => useTour('appointments', { ready: true })); await vi.advanceTimersByTimeAsync(400); expect(drive).not.toHaveBeenCalled(); // ولی دکمهٔ راهنما همچنان کار می‌کند result.current.start(); expect(drive).toHaveBeenCalledTimes(1); }); it('تا وقتی وضعیت از سرور نیامده اجرا نمی‌شود', async () => { get.mockRejectedValue(new Error('network down')); mountAppointmentsAnchors(); renderHookWithClient(() => useTour('appointments', { ready: true })); await vi.advanceTimersByTimeAsync(400); expect(drive).not.toHaveBeenCalled(); }); it('صفحه‌ای که هنوز آماده نیست تور نمی‌گیرد', async () => { get.mockResolvedValue({ data: { seen: {} } }); mountAppointmentsAnchors(); renderHookWithClient(() => useTour('appointments', { ready: false })); await vi.advanceTimersByTimeAsync(400); expect(drive).not.toHaveBeenCalled(); }); it('شناسهٔ ناشناس نه دکمه دارد نه اجرا', async () => { get.mockResolvedValue({ data: { seen: {} } }); const { result } = renderHookWithClient(() => useTour('nope', { ready: true })); await vi.advanceTimersByTimeAsync(400); expect(result.current.available).toBe(false); result.current.start(); expect(drive).not.toHaveBeenCalled(); }); it('وقتی هیچ المانی روی صفحه نیست، اجرا نمی‌شود', async () => { get.mockResolvedValue({ data: { seen: {} } }); const { result } = renderHookWithClient(() => useTour('appointments', { ready: true })); await waitFor(() => expect(result.current.available).toBe(true)); result.current.start(); expect(drive).not.toHaveBeenCalled(); }); it('بستن تور، دیده‌شدن را ثبت می‌کند', async () => { get.mockResolvedValue({ data: { seen: {} } }); post.mockResolvedValue({ data: { tourId: 'appointments', version: appointmentsTour.version } }); mountAppointmentsAnchors(); renderHookWithClient(() => useTour('appointments', { ready: true })); await vi.advanceTimersByTimeAsync(400); await waitFor(() => expect(destroyHandlers).toHaveLength(1)); destroyHandlers[0](); await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/my/tours/appointments/seen', { version: appointmentsTour.version, }), ); }); });