// Изолированные UI-регрессии: только вымышленные врачи и окна, без сети и заявок. import assert from 'node:assert/strict'; import { parseHTML } from 'linkedom'; import { createServer } from 'vite'; const { window } = parseHTML('
'); Object.assign(globalThis, { window, document: window.document, HTMLElement: window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true }); window.location = new URL('https://booking-test.invalid/'); // linkedom is a modern DOM but does not advertise input-event support to React's detector. document.oninput = null; const { default: React, act } = await import('react'); const { createRoot } = await import('react-dom/client'); const { Simulate } = await import('react-dom/test-utils'); const { MemoryRouter } = await import('react-router-dom'); const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' }); const { default: LeadModal } = await vite.ssrLoadModule('/src/components/LeadModal.jsx'); const { api } = await vite.ssrLoadModule('/src/api.js'); const { groupDoctors } = await vite.ssrLoadModule('/src/lib/doctorGroups.js'); const doctors = [ { id: 1, slug: 'first', full_name: 'Первый врач', booking: { provider: 'native' }, specialties: [{slug:'kids',name_plural:'Детские врачи'}] }, { id: 2, slug: 'second', full_name: 'Второй врач', booking: { provider: 'native' }, specialties: [{slug:'gyn',name_plural:'Гинекологи'}] }, { id: 3, slug: 'fallback', full_name: 'Врач без расписания', booking: { provider: 'none' }, specialties: [{slug:'kids',name_plural:'Детские врачи'}] }, ]; const pending = []; let doctorCatalogRequests = 0; api.doctors = async () => { doctorCatalogRequests++; return { data: doctors }; }; api.bookingAvailability = async () => ({ data: { enabled: true, days: [ { date: '2070-10-23', doctor_ids: [1, 2] }, { date: '2070-10-24', doctor_ids: [2] }, ] } }); api.doctorSlots = (slug) => new Promise((resolve, reject) => pending.push({ slug, resolve, reject })); api.createLead = api.createAppointment = () => { throw new Error('Real submissions are forbidden in this test'); }; const container = document.getElementById('root'); const root = createRoot(container); let closeCalls = 0; const render = async (props = {}) => act(async () => root.render(React.createElement(MemoryRouter, {}, React.createElement(LeadModal, { ...props, onClose() { closeCalls++; } })))); const text = () => container.textContent; const times = () => [...container.querySelectorAll('.slot-chip--time')].map((e) => e.textContent); const click = async (element) => act(async () => element.dispatchEvent(new window.Event('click', { bubbles: true }))); async function openPicker() { if (container.querySelector('.doctor-picker__trigger').getAttribute('aria-expanded') !== 'true') await click(container.querySelector('.doctor-picker__trigger')); } async function availableDoctorIDs() { await openPicker(); const ids = [...new Set([...container.querySelectorAll('[data-doctor-id]')].map((element) => element.dataset.doctorId))].sort(); await click(container.querySelector('.doctor-picker__trigger')); return ids; } async function chooseDoctor(id) { await openPicker(); if (id === '') { await click(container.querySelector('.doctor-picker__reset')); return; } const doctor = doctors.find((entry) => entry.id === id); await act(async () => Simulate.input(container.querySelector('.doctor-picker__search input'), {target:{value:doctor.full_name}})); await click(container.querySelector(`[data-doctor-id="${id}"]`)); } const free = (time) => ({ start: `2070-10-23T${time}:00+03:00`, status: 'free', branch_id: 'test', branch: 'Тестовый филиал' }); async function reply(request, slots, enabled = true) { await act(async () => request.resolve({ data: { enabled, slots, services: [] } })); } try { assert.deepEqual(groupDoctors(doctors).map((group) => group.name), ['Гинекологи','Детские врачи']); assert.equal(groupDoctors(doctors, 'детские')[0].doctors.length, 2, 'Search can find a whole specialty'); assert.equal(groupDoctors(doctors, 'врач первый')[0].doctors[0].id, 1, 'Name tokens can be entered in any order'); assert.equal(groupDoctors(doctors, 'несуществующий').length, 0); assert.equal(groupDoctors([{...doctors[0],specialties:[...doctors[0].specialties,...doctors[0].specialties]}])[0].doctors.length, 1, 'Duplicate specialty entries cannot duplicate a doctor'); await render(); assert.equal(pending.length, 0, 'No schedule requests before selecting a doctor'); assert.ok(container.querySelector('input[type="date"]')); assert.equal(container.querySelector('input[type="tel"]'), null); assert.deepEqual(times(), []); assert.doesNotMatch(text(), /Сначала выберите врача|Выберите дату или врача/); assert.equal(container.querySelector('.lead-modal__availability'), null, 'The removed initial hint must not leave an empty panel'); assert.equal(container.querySelector('.lead-modal__availability--warning'), null, 'Selection instructions must not look like an unavailable-schedule warning'); assert.deepEqual(await availableDoctorIDs(), ['1','2','3']); await openPicker(); assert.equal(container.querySelectorAll('.doctor-picker__direction').length, 2); await click(container.querySelector('.doctor-picker__direction')); assert.equal(container.querySelector('.doctor-picker__direction').getAttribute('aria-expanded'), 'true'); const escape = new window.Event('keydown', {bubbles:true}); escape.key = 'Escape'; await act(async () => container.querySelector('.doctor-picker__search input').dispatchEvent(escape)); assert.equal(container.querySelector('.doctor-picker__panel'), null, 'Escape closes the picker'); assert.equal(closeCalls, 0, 'Escape in the picker must not close the booking dialog'); await chooseDoctor(1); assert.equal(pending[0].slug, 'first'); assert.match(text(), /Загружаем свободное время/); await reply(pending[0], [free('09:15'), { ...free('10:00'), status: 'busy' }, { ...free('11:00'), start: '2000-01-01T11:00:00+03:00' }, { ...free('12:00'), start: 'invalid' }]); assert.deepEqual(times(), ['09:15'], 'Only actual free future slots appear'); await act(async () => container.querySelector('.slot-chip--time').dispatchEvent(new window.Event('click', { bubbles: true }))); assert.ok(container.querySelector('input[type="tel"]'), 'Contacts follow an actual slot'); await chooseDoctor(2); assert.deepEqual(times(), []); assert.equal(container.querySelector('input[type="tel"]'), null, 'Switching doctor clears the chosen slot'); assert.equal(pending[1].slug, 'second'); await chooseDoctor(1); assert.equal(pending[2].slug, 'first'); await reply(pending[1], [], false); assert.match(text(), /Загружаем свободное время/, 'Late response for another doctor must be ignored'); await reply(pending[2], [free('15:45')]); assert.deepEqual(times(), ['15:45']); await chooseDoctor(''); assert.deepEqual(times(), []); assert.equal(pending.length, 3, 'Clearing selection must not request an all-doctors schedule'); await chooseDoctor(3); assert.match(text(), /Свободные окна этого врача сейчас недоступны/); assert.match(container.querySelector('.lead-modal__availability--warning').textContent, /Свободные окна этого врача сейчас недоступны/, 'The unavailable-schedule message has its own prominent warning style'); assert.deepEqual(times(), []); assert.equal(container.querySelectorAll('.slot-chip--time').length, 0, 'Fallback cannot offer invented times'); assert.ok(container.querySelector('input[type="tel"]')); await chooseDoctor(2); await act(async () => pending[3].reject(new Error('Schedule temporarily unavailable'))); assert.match(text(), /Свободные окна этого врача сейчас недоступны/); assert.deepEqual(times(), []); await act(async () => root.render(null)); const beforeCardCatalogRequests = doctorCatalogRequests; await render({ doctor: doctors[0], slot: free('10:00') }); assert.equal(doctorCatalogRequests, beforeCardCatalogRequests, 'A doctor card must not load the all-doctors catalog'); assert.equal(container.querySelector('.doctor-picker__trigger'), null); assert.equal(container.querySelector('.doctor-picker__fixed').dataset.fixedDoctorId, '1'); await reply(pending[4], [free('16:15')]); assert.deepEqual(times(), ['16:15']); assert.equal(container.querySelector('input[type="tel"]'), null, 'A stale initial slot must not be preselected'); await act(async () => root.render(null)); await render(); await act(async () => Simulate.input(container.querySelector('input[type="date"]'), { target: { value: '2070-10-24' } })); assert.deepEqual(await availableDoctorIDs(), ['2'], 'Only doctors free on the selected date appear'); await chooseDoctor(2); await reply(pending.at(-1), [free('09:00'), { ...free('14:15'), start: '2070-10-24T14:15:00+03:00' }]); assert.deepEqual(times(), ['14:15'], 'Selected date restricts the actual time list too'); await click(container.querySelector('.slot-chip--time')); assert.ok(container.querySelector('input[type="tel"]')); await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '2070-10-25' } })); assert.deepEqual(times(), []); assert.equal(container.querySelector('input[type="tel"]'), null, 'Changing date clears the chosen time and contacts step'); assert.match(container.querySelector('.doctor-picker__trigger .doctor-picker__name').textContent, /Второй врач/, 'Changing date preserves the doctor even when no windows are available'); assert.deepEqual(await availableDoctorIDs(), ['2']); assert.match(text(), /У выбранного врача на эту дату свободных окон нет/); assert.ok(container.querySelector('.lead-modal__availability--warning')); await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '2070-10-23' } })); assert.match(container.querySelector('.doctor-picker__trigger .doctor-picker__name').textContent, /Второй врач/); assert.equal(pending.at(-1).slug, 'second'); await reply(pending.at(-1), [free('11:45')]); assert.deepEqual(times(), ['11:45'], 'Available date opens the preserved doctor schedule without a second selection'); await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '' } })); await reply(pending.at(-1), [free('12:45')]); assert.deepEqual(times(), ['12:45'], 'Clearing date keeps the doctor and restores all their dates'); assert.deepEqual(await availableDoctorIDs(), ['1','2','3'], 'Clearing date restores the doctor-first flow'); await act(async () => root.render(null)); const beforeLockedCatalogRequests = doctorCatalogRequests; await render({ doctor: doctors[0] }); await reply(pending.at(-1), [free('10:30')]); await act(async () => Simulate.input(container.querySelector('input[type="date"]'), { target: { value: '2070-10-23' } })); await reply(pending.at(-1), [free('13:30')]); assert.match(container.querySelector('.doctor-picker__fixed .doctor-picker__name').textContent, /Первый врач/); assert.equal(container.querySelector('.doctor-picker__trigger'), null, 'Doctor-card booking has no doctor selector'); assert.deepEqual(times(), ['13:30'], 'Doctor preselected from a card also survives calendar selection'); assert.equal(doctorCatalogRequests, beforeLockedCatalogRequests); await click(container.querySelector('.doctor-picker__fixed')); assert.equal(container.querySelector('.doctor-picker__panel'), null, 'Static doctor summary cannot open a dropdown'); assert.equal(container.querySelector('.doctor-picker__chevron'), null, 'No misleading dropdown arrow for a fixed doctor'); await click(container.querySelector('.slot-chip--time')); assert.ok(container.querySelector('input[type="tel"]')); await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '2070-10-24' } })); assert.deepEqual(times(), []); assert.equal(container.querySelector('input[type="tel"]'), null, 'Date changes clear the time, not the fixed doctor'); assert.equal(container.querySelector('.doctor-picker__fixed').dataset.fixedDoctorId, '1'); assert.match(text(), /У выбранного врача на эту дату свободных окон нет/); assert.doesNotMatch(text(), /другого специалиста/); assert.equal(container.querySelector('.doctor-picker__trigger'), null, 'An unavailable date cannot unlock the doctor'); await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '' } })); await reply(pending.at(-1), [free('14:30')]); assert.deepEqual(times(), ['14:30']); assert.equal(container.querySelector('.doctor-picker__fixed').dataset.fixedDoctorId, '1'); assert.equal(doctorCatalogRequests, beforeLockedCatalogRequests); await act(async () => root.render(null)); await render({ doctor: doctors[2] }); assert.match(text(), /Свободные окна этого врача сейчас недоступны/); assert.equal(container.querySelector('.doctor-picker__fixed').dataset.fixedDoctorId, '3'); assert.equal(container.querySelector('.doctor-picker__trigger'), null, 'Fallback requests also keep the card doctor fixed'); assert.ok(container.querySelector('input[type="tel"]')); assert.equal(doctorCatalogRequests, beforeLockedCatalogRequests); await act(async () => root.render(null)); await render({ doctor: doctors[0] }); await reply(pending.at(-1), [], false); assert.match(text(), /Свободные окна этого врача сейчас недоступны/); assert.equal(container.querySelector('.doctor-picker__fixed').dataset.fixedDoctorId, '1'); assert.equal(container.querySelector('.doctor-picker__trigger'), null, 'A failed native schedule cannot unlock the doctor'); assert.equal(doctorCatalogRequests, beforeLockedCatalogRequests); const originalDoctorLoader = api.doctors; for (const fail of [false, true]) { await act(async () => root.render(null)); api.doctors = async () => { if (fail) throw new Error('Fictional catalog outage'); return { data: [] }; }; await render(); assert.match(container.querySelector('.lead-modal__availability--warning').textContent, /Список врачей сейчас недоступен/, 'An empty or failed doctor catalog has the prominent red warning style'); } api.doctors = originalDoctorLoader; await act(async () => root.render(null)); await render({ form: 'callback' }); assert.ok(container.querySelector('input[type="tel"]')); assert.equal(container.querySelector('select'), null); await act(async () => root.render(null)); await render({ form: 'review' }); assert.equal(container.querySelectorAll('.lead-rating__star').length, 5); assert.ok(container.querySelector('select')); console.log('PASS: specialty grouping/search/selection, picker keyboard handling, date-to-doctor filtering, actual times, resets, stale responses, fixed doctor from card without catalog requests, date/fallback lock, callback and review'); } finally { await act(async () => root.unmount()); await vite.close(); }