dana-medical-site/web/scripts/test-booking-flow.mjs
2026-10-22 10:59:52 +00:00

149 lines
9.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Изолированные UI-регрессии: только вымышленные врачи и окна, без сети и заявок.
import assert from 'node:assert/strict';
import { parseHTML } from 'linkedom';
import { createServer } from 'vite';
const { window } = parseHTML('<html><body><div id="root"></div></body></html>');
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 = [];
api.doctors = async () => ({ 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.match(text(), /Выберите дату или врача/);
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.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));
await render({ doctor: doctors[0], slot: free('10:00') });
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 act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '2070-10-25' } }));
assert.deepEqual(times(), []);
assert.equal(container.querySelector('.doctor-picker__trigger .doctor-picker__name').textContent, 'Выберите врача', 'Changing date clears the selected doctor and window');
assert.deepEqual(await availableDoctorIDs(), []);
assert.match(text(), /На эту дату свободных окон нет/);
await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '' } }));
assert.deepEqual(await availableDoctorIDs(), ['1','2','3'], 'Clearing date restores the doctor-first flow');
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, fallback, callback and review');
} finally {
await act(async () => root.unmount());
await vite.close();
}