diff --git a/web/scripts/test-menu-switch.mjs b/web/scripts/test-menu-switch.mjs new file mode 100644 index 0000000..886a475 --- /dev/null +++ b/web/scripts/test-menu-switch.mjs @@ -0,0 +1,92 @@ +// Regression for cross-menu data leaks. All catalog responses are fictional; +// no network calls or patient data are used. +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://menu-test.invalid/'); +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: Header } = await vite.ssrLoadModule('/src/components/Header.jsx'); +const { api } = await vite.ssrLoadModule('/src/api.js'); +const { ApiDataProvider } = await vite.ssrLoadModule('/src/context/ApiData.jsx'); +const pending = []; +for (const [method, kind] of [['services', 'services'], ['specialties', 'doctors'], ['priceSections', 'prices']]) { + api[method] = ({ signal }) => new Promise((resolve, reject) => pending.push({ kind, signal, resolve, reject })); +} +const container = document.getElementById('root'); +const root = createRoot(container); +const prices = [{ id: 1, name: 'Тестовая консультация', slug: 'test-price', count: 411 }]; +const specialties = [{ id: 2, name: 'Тестовые педиатры', slug: 'test-kids', count: 3 }]; +const services = [{ id: 3, name: 'Тестовая услуга', path: 'test-service', children: [] }]; +const panel = () => container.querySelector('.mega'); +const panelText = () => panel()?.textContent || ''; +const links = () => [...(panel()?.querySelectorAll('.mega__item') || [])].map((link) => link.getAttribute('href')); +const trigger = (title) => [...container.querySelectorAll('.nav__item')].find((node) => node.textContent === title); +async function open(title, focus = false) { + await act(async () => Simulate[focus ? 'focus' : 'mouseEnter'](trigger(title))); + return pending.at(-1); +} +async function reply(request, data) { await act(async () => request.resolve({ data })); } +async function render(initial = {}) { + await act(async () => root.render(React.createElement(MemoryRouter, {}, + React.createElement(ApiDataProvider, { value: initial }, React.createElement(Header))))); +} + +try { + await render(); + const priceRequest = await open('Цены'); + await reply(priceRequest, prices); + assert.match(panelText(), /411 услуг/); + const doctorRequest = await open('Врачи'); + assert.equal(panel().getAttribute('aria-label'), 'Меню «Врачи»'); + assert.deepEqual(links(), [], 'Price sections must disappear immediately when switching to doctors'); + assert.match(panelText(), /Загружаем/); + assert.equal(priceRequest.signal.aborted, true, 'Previous menu request is cancelled on switch'); + await reply(doctorRequest, specialties); + assert.match(panelText(), /3 врача/); + assert.deepEqual(links(), ['/company/staff/test-kids/']); + assert.doesNotMatch(panelText(), /411|Тестовая консультация/); + + const delayedPrice = await open('Цены'); + assert.deepEqual(links(), [], 'Doctor specialties must not leak into the price menu'); + const delayedServices = await open('Услуги'); + const latestDoctor = await open('Врачи', true); + assert.equal(delayedPrice.signal.aborted, true); + assert.equal(delayedServices.signal.aborted, true); + await reply(latestDoctor, specialties); + // Deliberately resolve cancelled requests: even a transport ignoring AbortSignal + // must not overwrite a newly opened menu. + await reply(delayedPrice, prices); + await reply(delayedServices, services); + assert.deepEqual(links(), ['/company/staff/test-kids/']); + assert.match(panelText(), /3 врача/); + assert.doesNotMatch(panelText(), /411|Тестовая консультация|Тестовая услуга/); + + const serviceRequest = await open('Услуги'); + await reply(serviceRequest, services); + assert.deepEqual(links(), ['/services/test-service/']); + const failedPrice = await open('Цены'); + await act(async () => failedPrice.reject(new Error('Fictional failure'))); + assert.match(panelText(), /Не удалось загрузить список/); + assert.deepEqual(links(), [], 'An error must not leave links from another menu'); + + await act(async () => root.render(null)); + await render({ specialties: { data: specialties, meta: {} } }); + const initialPrices = await open('Цены'); + await reply(initialPrices, prices); + const initialDoctors = await open('Врачи', true); + assert.deepEqual(links(), ['/company/staff/test-kids/'], 'SSR data belongs only to its own catalog'); + assert.match(panelText(), /3 врача/); + await reply(initialDoctors, specialties); + console.log('PASS: hover/focus menu switching, delayed replies, cancellation, errors, and SSR data isolation'); +} finally { + await act(async () => root.unmount()); + await vite.close(); +} diff --git a/web/src/components/Header.jsx b/web/src/components/Header.jsx index aa2c9b7..e3f5256 100644 --- a/web/src/components/Header.jsx +++ b/web/src/components/Header.jsx @@ -115,7 +115,9 @@ export default function Header() { {openMenu && (
- setOpenMenu(null)} /> + {/* Каталоги имеют разную структуру: при смене меню сбрасываем + данные и отменяем запрос предыдущего, не меняя лишь заголовок. */} + setOpenMenu(null)} />
)}