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

163 lines
11 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.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));
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(), /На эту дату свободных окон нет/);
assert.equal(container.querySelector('.lead-modal__availability--warning'), null, 'An empty selected date keeps its informational style');
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');
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, fallback, callback and review');
} finally {
await act(async () => root.unmount());
await vite.close();
}