dana-medical-site/web/scripts/test-booking-flow.mjs

223 lines
15 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 = [];
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();
}