From 519de26839fe813077f56d9c3cf138875e8d5a5e Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 1 Aug 2026 16:06:49 +0330 Subject: [PATCH] fix(theme): make dark mode actually apply to the public pages, and test the modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tailwind was configured with darkMode: "class" while next-themes writes data-theme="dark" on the public pages. Every dark: utility on the public site — 111 of them — compiled to a selector that never matched, which is why the booking flow stayed white in dark mode. The variant strategy now accepts both .dark (the panel) and [data-theme="dark"] (the public pages), so neither provider had to change and 66 dark rules now compile against the real attribute. The cancel and reschedule modals get tests, the first component tests in this repo. They pin the things that would be silently wrong: the penalty comes from the server before anything is cancelled, the free window says "no penalty" rather than showing a zero, confirming actually sends the request (the old dialog's confirm button only closed it), a failed preview does not block the cancellation, slots are requested with exclude_appointment_uuid so the patient's own hour is not shown as taken, and the reschedule sends only the start time because the server owns the duration. The amount assertion deliberately checks the number and unit rather than the digit shape — numberToArStyle uses the ar-AE locale and its output depends on the ICU data in the environment. lib/getStateInfo.test.js had been failing since before this work: an unknown host falls through to the representation API, so the test made a real network call and timed out after five seconds. It mocks lib/req now. The suite is fully green for the first time: 158 tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) --- .../modal/RescheduleModal.test.js | 105 ++++++++++++++++++ .../turns/isTurnsDetails/modal/index.test.js | 90 +++++++++++++++ lib/getStateInfo.test.js | 6 + tailwind.config.js | 14 ++- 4 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/RescheduleModal.test.js create mode 100644 components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/index.test.js diff --git a/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/RescheduleModal.test.js b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/RescheduleModal.test.js new file mode 100644 index 0000000..0b8b622 --- /dev/null +++ b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/RescheduleModal.test.js @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +vi.mock('@/services/response', () => ({ + request: { + getServiceSlotsForReschedule: vi.fn(), + serviceReschedule: vi.fn(), + }, +})); + +import { request } from '@/services/response'; +import RescheduleModal from './RescheduleModal'; + +const appointment = { + uuid: 'a-1', + doctor: { uuid: 'd-1' }, + service_items: [{ uuid: 's-1' }, { uuid: 's-2' }], +}; + +const slots = { + data: { + total_duration_minutes: 30, + start_times: [ + { start: 1_900_000_000, end: 1_900_001_800, start_time: '09:00', end_time: '09:30' }, + { start: 1_900_003_600, end: 1_900_005_400, start_time: '10:00', end_time: '10:30' }, + ], + }, +}; + +async function openAndPickFirstDay(user) { + render(); + await user.click(screen.getByRole('button', { name: 'جابه‌جایی نوبت' })); + + const days = screen.getAllByRole('button').filter((b) => /\d|[۰-۹]/.test(b.textContent)); + await user.click(days[0]); +} + +describe('مودال جابه‌جایی نوبت سایت', () => { + beforeEach(() => { + request.getServiceSlotsForReschedule.mockResolvedValue(slots); + request.serviceReschedule.mockResolvedValue({ data: {} }); + }); + + /** + * ⭐ بدون `exclude_appointment_uuid`، بیمار ساعت خودش را «پر» می‌بیند و نمی‌تواند + * حتی به همان روز جابه‌جا شود. + */ + it('وقت‌ها را با کنارگذاشتن نوبت فعلی می‌گیرد', async () => { + const user = userEvent.setup(); + await openAndPickFirstDay(user); + + await waitFor(() => expect(request.getServiceSlotsForReschedule).toHaveBeenCalled()); + + const [doctorUuid, , appointmentUuid, serviceUuids] = + request.getServiceSlotsForReschedule.mock.calls[0]; + + expect(doctorUuid).toBe('d-1'); + expect(appointmentUuid).toBe('a-1'); + expect(serviceUuids).toEqual(['s-1', 's-2']); + }); + + /** مدت را سرور حساب می‌کند؛ فرانت فقط زمان شروع می‌فرستد. */ + it('فقط زمان شروع را می‌فرستد، نه مدت', async () => { + const onDone = vi.fn(); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: 'جابه‌جایی نوبت' })); + + const days = screen.getAllByRole('button').filter((b) => /\d|[۰-۹]/.test(b.textContent)); + await user.click(days[0]); + + const slot = await screen.findByRole('button', { name: '09:00' }); + await user.click(slot); + await user.click(screen.getByRole('button', { name: 'جابه‌جا کن' })); + + await waitFor(() => + expect(request.serviceReschedule).toHaveBeenCalledWith('a-1', { start: 1_900_000_000 }), + ); + await waitFor(() => expect(onDone).toHaveBeenCalled()); + }); + + it('روزِ بدون وقت آزاد را صریح می‌گوید', async () => { + request.getServiceSlotsForReschedule.mockResolvedValue({ data: { start_times: [] } }); + const user = userEvent.setup(); + + await openAndPickFirstDay(user); + + expect(await screen.findByText(/در این روز وقت آزادی نیست/)).toBeInTheDocument(); + }); + + /** ۴۰۹ یعنی همین لحظه کس دیگری گرفت — پیام سرور باید دیده شود. */ + it('پیام خطای سرور را نشان می‌دهد', async () => { + request.serviceReschedule.mockRejectedValue(new Error('این بازه زمانی قبلاً رزرو شده است')); + const user = userEvent.setup(); + + await openAndPickFirstDay(user); + + await user.click(await screen.findByRole('button', { name: '09:00' })); + await user.click(screen.getByRole('button', { name: 'جابه‌جا کن' })); + + expect(await screen.findByText('این بازه زمانی قبلاً رزرو شده است')).toBeInTheDocument(); + }); +}); diff --git a/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/index.test.js b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/index.test.js new file mode 100644 index 0000000..7b02f54 --- /dev/null +++ b/components/dashboard/userAccount/sidebars/turns/isTurnsDetails/modal/index.test.js @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +vi.mock('@/services/response', () => ({ + request: { + getCancellationPreview: vi.fn(), + cancelAppointment: vi.fn(), + }, +})); + +import { request } from '@/services/response'; +import ModalDeleteComment from './index'; + +const appointment = { uuid: 'a-1', slot_start: 2_000_000_000, status: 'confirmed' }; + +const preview = (over = {}) => ({ + data: { + penalty_rials: 2_500_000, + deposit_refundable: true, + credit_refundable: true, + within_free_window: false, + notes: [], + paid_rials: 5_000_000, + ...over, + }, +}); + +describe('مودال لغو نوبت سایت', () => { + beforeEach(() => { + request.cancelAppointment.mockResolvedValue({ data: {} }); + }); + + /** ⭐ تا پیش از این، دکمهٔ تأیید فقط پنجره را می‌بست و هیچ درخواستی نمی‌رفت. */ + it('پیش از تأیید، جریمه را از سرور نشان می‌دهد', async () => { + request.getCancellationPreview.mockResolvedValue(preview()); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' })); + + // ۲٬۵۰۰٬۰۰۰ ریال ⇒ ۲۵۰٬۰۰۰ تومان. قالب رقم به ICU محیط وابسته است، پس فقط + // «مبلغ درست + واحد» سنجیده می‌شود نه شکل رقم‌ها. + const amount = await screen.findByText(/تومان$/); + expect(amount.textContent.replace(/[^0-9۰-۹٠-٩]/g, '')).toMatch(/2500{2}0|۲۵۰۰۰۰|٢٥٠٠٠٠/); + expect(request.cancelAppointment).not.toHaveBeenCalled(); + }); + + it('در بازهٔ رایگان «بدون جریمه» می‌گوید، نه صفر', async () => { + request.getCancellationPreview.mockResolvedValue( + preview({ penalty_rials: 0, within_free_window: true }), + ); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' })); + + expect(await screen.findByText('بدون جریمه')).toBeInTheDocument(); + expect(screen.getByText('این لغو در بازهٔ رایگان است.')).toBeInTheDocument(); + }); + + it('تأیید، لغو را واقعاً می‌فرستد و والد را خبر می‌کند', async () => { + request.getCancellationPreview.mockResolvedValue(preview()); + const onDone = vi.fn(); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' })); + await screen.findByText(/جریمهٔ لغو/); + + await user.click(screen.getByRole('button', { name: 'لغو نوبت' })); + + await waitFor(() => + expect(request.cancelAppointment).toHaveBeenCalledWith('a-1', { by: 'user' }), + ); + await waitFor(() => expect(onDone).toHaveBeenCalled()); + }); + + /** پیش‌نمایشی که نیامده نباید لغو را قفل کند — ولی باید صریح بگوید نیامده. */ + it('نبودِ پیش‌نمایش، لغو را قفل نمی‌کند', async () => { + request.getCancellationPreview.mockRejectedValue(new Error('down')); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: 'لغو کردن نوبت' })); + + expect(await screen.findByText(/پیامد مالی لغو در دسترس نیست/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'لغو نوبت' })).not.toBeDisabled(); + }); +}); diff --git a/lib/getStateInfo.test.js b/lib/getStateInfo.test.js index f40cb1d..e78fefb 100644 --- a/lib/getStateInfo.test.js +++ b/lib/getStateInfo.test.js @@ -6,6 +6,12 @@ vi.mock('next/headers', () => ({ headers: async () => ({ get: (k) => (k === 'host' ? hostRef.value : null) }), })); +// هاستِ ناشناخته به API نمایندگی می‌رود؛ بدون این mock تست به شبکهٔ واقعی می‌زند و +// بعد از پنج ثانیه timeout می‌شود — همان شکستِ قدیمیِ این فایل. +vi.mock('@/lib/req', () => ({ + fetchReq: async () => null, +})); + import { getStateInfo } from '@/lib/getStateInfo'; beforeEach(() => { diff --git a/tailwind.config.js b/tailwind.config.js index 4515817..effef39 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -24,5 +24,17 @@ module.exports = { }, }, plugins: [], - darkMode: "class", + /** + * دو سازوکار تم هم‌زمان در پروژه هست و هر دو باید کار کنند: + * صفحات عمومی با `data-theme="dark"` (از `next-themes` با `attribute="data-"`) + * و پنل با کلاس `.dark`. + * + * تا امروز فقط `class` تعریف شده بود، پس **همهٔ `dark:`های صفحات عمومی هرگز اجرا + * نمی‌شدند** — جریان رزرو در دارک‌مود سفید می‌ماند. استراتژی `variant` هر دو را + * می‌پذیرد و هیچ‌کدام را عوض نمی‌کند. + */ + darkMode: [ + "variant", + ["&:where(.dark, .dark *)", '&:where([data-theme="dark"], [data-theme="dark"] *)'], + ], };