182 lines
12 KiB
JavaScript
182 lines
12 KiB
JavaScript
// Изолированные 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.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));
|
||
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 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));
|
||
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__trigger .doctor-picker__name').textContent, /Первый врач/);
|
||
assert.deepEqual(times(), ['13:30'], 'Doctor preselected from a card also survives calendar selection');
|
||
|
||
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();
|
||
}
|