// Сценарии ИНТЕРАКЦИЙ: клик → кадр → проверка состояния. Статичный снимок половину пака S3.5 // не принимает — вкладки, драг ручки, модалы и спойлеры существуют только в движении. // // node scripts/scenes.mjs все сценарии, кадры в .shots/scenes/ // node scripts/scenes.mjs tabs drag только названные // // Падение сценария роняет команду: это приёмка, а не демонстрация. import { mkdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const shotsDir = resolve(root, '.shots/scenes'); const toolingRoot = resolve(root, '.tooling/root'); if (existsSync(toolingRoot)) { const libs = resolve(toolingRoot, 'usr/lib/x86_64-linux-gnu'); process.env.LD_LIBRARY_PATH = [libs, process.env.LD_LIBRARY_PATH].filter(Boolean).join(':'); process.env.XDG_DATA_HOME = resolve(toolingRoot, 'usr/share'); } const { chromium } = await import('playwright'); const { default: AxeBuilder } = await import('@axe-core/playwright'); const { build, preview } = await import('vite'); /** @typedef {import('playwright').Page} Page */ /** @typedef {import('playwright').Locator} Locator */ /** @typedef {(name: string) => Promise} Shot */ /** @param {boolean} condition @param {string} what */ function check(condition, what) { if (!condition) throw new Error(`сценарий провален: ${what}`); console.log(` ✓ ${what}`); } /** * Прогон axe по ОТКРЫТОМУ состоянию. `npm run shot` проверяет только маршруты, то есть экран, * который открывается кликом (таблица банка, модалы), гейта доступности не видел вовсе. * Политика та же, что в shot.mjs: контраст — в отчёт, остальное роняет сценарий. * @param {Page} page @param {string} what */ async function audit(page, what) { const axe = await new AxeBuilder({ page }).analyze(); const blocking = axe.violations.filter((v) => v.id !== 'color-contrast'); for (const v of axe.violations.filter((v) => v.id === 'color-contrast')) { console.log(` контраст (принято как есть): ${v.nodes.length} узл(ов)`); } if (blocking.length > 0) { for (const v of blocking) { console.error(` ✖ ${v.id}: ${v.help} — ${v.nodes.length} узл(ов)`); // Узел печатается сразу: без него «одно нарушение» приходится искать вручную. for (const node of v.nodes) console.error(` ${node.target.join(' ')} — ${node.html.slice(0, 160)}`); } throw new Error(`Доступность: ${blocking.length} нарушени(й) — ${what}`); } console.log(` ✓ гейт доступности: ${what}`); } /** @type {Record Promise>} */ const scenes = { // Замечание 2 + приёмка промта: пять одиночных кликов — одна вкладка предпросмотра. /** @param {Page} page @param {Shot} shot */ async tabs(page, shot) { await open(page, '/showcase'); const tabs = page.locator('[role="tab"]'); // Строки дерева ищутся ВНУТРИ левой панели: те же названия разделов стоят и в сводке // замечаний справа, и без области поиска локатор указывает на две вещи разом. /** @param {string} name */ const chapter = (name) => page.locator('nav').getByText(name, { exact: true }); for (const name of [ 'Нет раскаяния', 'Церемония открытия', 'Деревня Гуюэ', 'Аптека', 'Первый гу', ]) { await chapter(name).click(); } await shot('tabs-preview'); check( (await page.locator('[role="tab"][data-preview="true"]').count()) === 1, 'после пяти одиночных кликов ровно одна вкладка предпросмотра', ); const before = await tabs.count(); await chapter('Аптека').dblclick(); check( (await page.locator('[role="tab"][data-preview="true"]').count()) === 0, 'двойной клик по главе закрепляет вкладку — предпросмотра не осталось', ); await chapter('Кровь на снегу').click(); await shot('tabs-pinned'); check( (await tabs.count()) === before + 1 && (await page.locator('[role="tab"][data-preview="true"]').count()) === 1, 'следующий одиночный клик открывает НОВУЮ вкладку предпросмотра рядом с закреплённой', ); // Двойной клик по самой вкладке — второй способ закрепления из модели VS Code. await page.locator('[role="tab"][data-preview="true"]').dblclick(); check( (await page.locator('[role="tab"][data-preview="true"]').count()) === 0, 'двойной клик по вкладке предпросмотра закрепляет её', ); // ⚠ Двойной клик МИМО строки раздела не должен закреплять ничего. Прежняя реализация // брала «текущий выбор», а книга и пустое место выбор не двигают — закреплялась чужая // вкладка (нашло адверсариальное ревью замером, не рассуждением). await chapter('Горная тропа').click(); const pinnedBefore = await tabs.count(); await page.locator('nav').getByText('Гу Чжэньжэнь', { exact: true }).dblclick(); check( (await tabs.count()) === pinnedBefore && (await page.locator('[role="tab"][data-preview="true"]').count()) === 1, 'двойной клик по строке КНИГИ вкладок не трогает', ); // Клавиатурный близнец двойного клика: Enter по выбранной строке закрепляет её. await page.keyboard.press('Enter'); check( (await page.locator('[role="tab"][data-preview="true"]').count()) === 1, 'Enter по строке книги вкладок тоже не трогает', ); await chapter('Горная тропа').click(); await page.keyboard.press('Enter'); check( (await page.locator('[role="tab"][data-preview="true"]').count()) === 0, 'Enter по выбранной строке раздела закрепляет вкладку', ); // ⚠ Opening a chapter must not flash a card. Watch OPACITY, not the DOM: the card is held back // until the wait is worth explaining. The press is held 80ms, as a hand holds it — that is the // window the chapter is read in, before the click completes. await page.evaluate(() => { /** @type {number[]} */ const seen = []; Object.assign(window, { __cards: seen }); const tick = () => { const card = document.querySelector('main h2')?.closest('div'); if (card && Number(getComputedStyle(card).opacity) > 0.02) seen.push(1); if (seen.length < 1) requestAnimationFrame(tick); }; requestAnimationFrame(tick); }); const row = await chapter('Утраченный свиток').boundingBox(); if (!row) throw new Error('строка раздела не найдена'); await page.mouse.move(row.x + row.width / 2, row.y + row.height / 2); await page.mouse.down(); await page.waitForTimeout(80); await page.mouse.up(); await page.waitForTimeout(900); check( (await page.evaluate(() => Reflect.get(window, '__cards'))).length === 0, 'открытие раздела не мигает карточкой ожидания перед текстом', ); // Замечание второго круга 4: короткое название усекалось раньше длинного соседа, потому что // вкладки делили нехватку места пропорционально ширине. Теперь ряд прокручивается, и // усечение бывает ТОЛЬКО у вкладки, упёршейся в свой предел. const clipped = await page.locator('[role="tab"]').evaluateAll((nodes) => nodes .map((node) => { const title = node.querySelector('span'); const max = parseFloat(getComputedStyle(node).maxWidth); return { text: title?.textContent ?? '', cut: (title?.scrollWidth ?? 0) > (title?.clientWidth ?? 0) + 1, atMax: node.getBoundingClientRect().width >= max - 1, }; }) .filter((tab) => tab.cut && !tab.atMax), ); check( clipped.length === 0, `ни одна вкладка не усечена, не упёршись в предел ширины (усечено: ${clipped.length})`, ); }, // Ф-19: переключение вкладки больше не размонтирует панель, прокрутка живёт. /** @param {Page} page @param {Shot} shot */ async scroll(page, shot) { await open(page, '/scale'); const bank = page.locator('[role="grid"][aria-label="Термины банка"]'); await bank.evaluate((node) => node.scrollTo(0, 9000)); const before = await bank.evaluate((node) => node.scrollTop); check(before > 8000, `прокрутка банка встала на ${String(before)}px`); await page.getByRole('tab', { name: 'О книге' }).click(); await page.getByRole('tab', { name: 'Банк' }).click(); await shot('scroll-kept'); const after = await bank.evaluate((node) => node.scrollTop); check( after === before, `после ухода на соседнюю вкладку и возврата офсет тот же (${String(after)})`, ); }, // Замечание 18: полоска ресайза не залипает после отпускания драга. /** @param {Page} page @param {Shot} shot */ async drag(page, shot) { await open(page, '/showcase'); const separator = page.locator('[role="separator"]').first(); const box = await separator.boundingBox(); if (!box) throw new Error('разделитель не найден'); const y = box.y + box.height / 2; await page.mouse.move(box.x + box.width / 2, y); await page.mouse.down(); await page.mouse.move(box.x + 90, y, { steps: 12 }); await shot('drag-active'); const dragging = await handleStyle(separator); // Числа — из vojo (style.css.ts): драг 0.55 и рост пилюли до 48px. check( dragging.opacity === '0.55', `в драге пилюля проступает на ${dragging.opacity}, как в vojo`, ); check(dragging.height === '48px', `в драге пилюля вырастает до ${dragging.height}, как в vojo`); await page.mouse.up(); await page.mouse.move(box.x + 400, y + 200); await shot('drag-released'); const released = await handleStyle(separator); check( released.opacity === '0', `после отпускания пилюля погасла (opacity ${released.opacity}), полоска не залипла`, ); check( (await page.locator('[role="separator"]').first().getAttribute('data-separator')) === 'focus', 'библиотека при этом ДЕРЖИТ data-separator="focus" — залипание лечится стилем, а не удачей', ); }, // Замечания 7, 8, 13, 20: панель контекста — сводка замечаний, банк со спойлерами, честное // состояние вместо кнопки-заглушки. /** @param {Page} page @param {Shot} shot */ async context(page, shot) { await open(page, '/showcase'); const panel = page.locator('aside'); await panel.getByRole('tab', { name: 'Замечания' }).click(); await shot('context-notes'); // Ищем в СВОЁМ списке: соседняя вкладка при keep-alive тоже в DOM, и `[role="option"]` // по всей панели считает заодно строки банка. const summary = panel.locator('[role="listbox"][aria-label="Замечания книги"] [role="option"]'); // Ровно столько, сколько замечаний в фикстуре книги. Проверка не косметическая: ключ строки, // собранный из раздела, схлопывал три замечания одного раздела в одну строку. check( (await summary.count()) === 4, 'сводка показывает ВСЕ замечания книги, включая несколько в одном разделе', ); await summary.first().click(); check( (await page.locator('[role="tab"][data-preview="true"]').count()) === 1, 'строка сводки открывает свой раздел вкладкой предпросмотра', ); }, // Замечание второго круга 7: банк — ТАБЛИЦА в правой панели (не вкладка в центре), // со столбцами, поиском, фильтром по типу и спойлером на описании, а не на переводе. /** @param {Page} page @param {Shot} shot */ async bank(page, shot) { await open(page, '/showcase'); const panel = page.locator('aside'); await panel.getByRole('tab', { name: 'Банк' }).click(); await shot('bank-table'); const table = panel.getByRole('grid', { name: 'Термины банка' }); check(await table.isVisible(), 'банк живёт в правой панели и показан таблицей'); check( (await page.getByRole('grid', { name: 'Термины банка' }).count()) === 1, 'таблица банка в приложении ровно одна — дубля в центре нет', ); // Утверждаем ВЕСЬ набор целиком: проверка «нет столбца „Состояние“» прошла бы и на столбце // «Подпись», то есть стерегла бы слово, а не устройство экрана. // Сравниваем в нижнем регистре: заголовки набраны капителью средствами CSS, и `innerText` // отдаёт их уже прописными — проверять надо СОСТАВ столбцов, а не приём набора. const columns = (await table.getByRole('columnheader').allInnerTexts()).map((name) => name.toLowerCase(), ); check( JSON.stringify(columns) === JSON.stringify(['термин', 'перевод', 'тип', 'смысл']), `столбцы таблицы ровно те: ${columns.join(' · ')} — состояния подписи среди них нет`, ); // Owner's report, 10.08: the header's ground sat on a rowgroup of ZERO height and so painted // nothing — scrolled rows read straight through the column titles. Ask what is drawn there. await table.evaluate((node) => { node.scrollTop = 400; }); // What catches the defect is the GROUND'S OPACITY: `elementsFromPoint` follows paint order, not // transparency (checked against the counterfactual). The hit test is only a gate — while a // scroll runs the virtualizer takes the pointer off the content and the grid itself answers. const header = await page .waitForFunction( () => { const node = document.querySelector('[role="grid"]'); const th = node?.querySelector('[role="columnheader"]'); if (!th) return null; const box = th.getBoundingClientRect(); const [top] = document.elementsFromPoint( box.left + box.width / 2, box.top + box.height / 2, ); if (top === node) return null; // указатель ещё снят — прокрутка не улеглась const parts = getComputedStyle(th).backgroundColor.match(/[\d.]+/g) ?? []; return { role: top?.getAttribute('role'), opacity: Number(parts[3] ?? 1) }; }, null, { timeout: 5000 }, ) .then((handle) => handle.jsonValue()); check( header?.role === 'columnheader' && header.opacity === 1, `прокрученная строка не просвечивает сквозь шапку (сверху ${String(header?.role)}, плотность фона ${String(header?.opacity)})`, ); await table.evaluate((node) => { node.scrollTop = 0; }); // Owner's report, 10.08: a panel squeezed to its stop and let back out left the table // squeezed, with a gap down its right side. Columns must fill the table at ANY width. const filled = async (/** @type {string} */ what) => { const spread = await table.evaluate((node) => { const heads = [...node.querySelectorAll('[role="columnheader"]')].sort( (a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left, ); const sum = heads.reduce((total, th) => total + th.getBoundingClientRect().width, 0); // The prose column — the last one — stretches and clips legitimately: no width fits a // description. The REST are sized off their own content, so clipping there is a defect. const prose = Math.round(heads.at(-1)?.getBoundingClientRect().left ?? 0); const cut = []; for (const cell of node.querySelectorAll('[role="gridcell"],[role="rowheader"]')) { const inner = cell.firstElementChild; if (!inner || Math.round(cell.getBoundingClientRect().left) === prose) continue; if (inner.scrollWidth > inner.clientWidth) cut.push(inner.textContent ?? ''); } return { sum: Math.round(sum), grid: Math.round(node.getBoundingClientRect().width), content: node.scrollWidth, cut, }; }); // Against the CONTENT width, not the window: the latter holds arithmetically whenever there // is room to spare, so on its own it guards almost nothing. check( Math.abs(spread.sum - spread.content) <= 1, `${what}: столбцы занимают таблицу целиком (${spread.sum} из ${spread.content}, окно ${spread.grid})`, ); // ⚠ And it does not run past the panel: columns MUST give way when space runs short. A narrow // panel catches it (before the fix: content 555, window 520). check( spread.content <= spread.grid + 1, `${what}: таблица не уезжает за край панели (содержимое ${spread.content}, окно ${spread.grid})`, ); return spread.cut; }; /** @param {string} what @param {string[]} cut */ const fits = (what, cut) => check( cut.length === 0, `${what}: столбцы по содержимому показывают его целиком (усечено: ${cut.join(' · ') || 'ничего'})`, ); fits('на своей ширине', await filled('на своей ширине')); // At its stop the columns are cramped, and it shows whether they give way. Clipping is legitimate // here, running past the panel is not. // ⚠ Each drag's offsets count from ITS OWN start: letting the panel back out needs a second drag. await pull(page, [400]); await filled('панель сжата до упора'); await pull(page, [-200]); fits('после сжатия до упора и возврата', await filled('после сжатия до упора и возврата')); // Спойлер закрывает ОПИСАНИЕ, перевод остаётся читаемым. const row = table.getByRole('row').filter({ hasText: 'Бессмертный феникс' }); // Разбираем цвет на числа, а не сравниваем со строкой: литеральный цвет в коде запрещён // гейтом, и сравнивать всё равно надо со свойством, а не с тем, как его печатает браузер. const covered = await row.locator('[data-sense]').evaluate((node) => { const style = getComputedStyle(node); return { blur: style.filter, clips: style.overflow !== 'visible' }; }); // ⚠ The second half guards the square edge: a blur mixes in what lies BEYOND the ink, so // anything clipping at the glyphs turns it into a rectangle. check( covered.blur.startsWith('blur(') && !covered.clips, `описание закрыто настоящим размытием и ничем не обрезано (${covered.blur}, обрезка ${String(covered.clips)})`, ); check( await row.getByText('Бессмертный феникс', { exact: true }).isVisible(), 'сам перевод при этом виден', ); await row.locator('[data-sense]').hover(); await page.waitForTimeout(250); // переход цвета 140ms — иначе читаем середину анимации check( !(await isCovered(row.locator('[data-sense]'))), 'наведение на ячейку раскрывает описание', ); // Скринридеру блюр не закрывает ничего, поэтому описание вынуто из дерева доступности, // а раскрывает его переключатель — единственный способ, работающий и для мыши, и для // клавиатуры, и для чтеца. check( (await row.locator('[data-sense]').getAttribute('aria-hidden')) === 'true', 'пока не раскрыто, описание вынуто из дерева доступности', ); await panel.getByRole('button', { name: 'Показать смыслы терминов' }).click(); await page.waitForTimeout(300); const revealed = await row.locator('[data-sense]').evaluate((node) => { const parts = getComputedStyle(node).color.match(/[\d.]+/g) ?? []; return { hidden: node.getAttribute('aria-hidden'), filter: getComputedStyle(node).filter, // A revealed line does NOT fade out: a tail mask read as the end of the line going dark // (owner's report, 10.08), and over a blur it clipped the whole thing into a rectangle. mask: getComputedStyle(node).maskImage, opacity: Number(parts[3] ?? 1), pressed: document.querySelector('[aria-pressed]')?.getAttribute('aria-pressed'), }; }); check( revealed.hidden === null && revealed.opacity === 1 && revealed.filter === 'none' && revealed.mask === 'none', `переключатель раскрывает описания и для глаз, и для чтеца (${JSON.stringify(revealed)})`, ); await panel.getByRole('button', { name: 'Скрыть смыслы терминов' }).click(); await page.waitForTimeout(250); // Обход стрелками НЕ раскрывает описания: иначе спойлер вскрывался бы по одному на нажатие, // а клавиатуре служит переключатель выше. await page.mouse.move(0, 0); // Кликаем по ЯЧЕЙКЕ: сам `role="row"` — контейнер нулевой высоты, кликать по нему нечем, // и человек тоже попадает в ячейку. await table.getByRole('rowheader').first().click(); await page.keyboard.press('ArrowDown'); await page.waitForTimeout(250); const walked = await page.evaluate(() => { const cell = document.activeElement?.closest('[role="row"]')?.querySelector('[data-sense]'); return cell ? getComputedStyle(cell).filter !== 'none' : null; }); check(walked === true, `обход строк с клавиатуры описания не вскрывает (закрыто: ${walked})`); // ⚠ The spoiler must hold in FORCED COLOURS too: the glyph-shadow version vanished there // outright — the system drops `text-shadow` — and printed every description in plain text. await page.emulateMedia({ forcedColors: 'active' }); await page.waitForTimeout(200); const forced = await row .locator('[data-sense]') .evaluate((node) => ({ filter: getComputedStyle(node).filter })); check( forced.filter.startsWith('blur('), `в режиме высокой контрастности описание остаётся закрытым (${JSON.stringify(forced)})`, ); await page.emulateMedia({ forcedColors: 'none' }); await page.waitForTimeout(200); // Спойлер не должен утекать мимо блюра. Подсказки у термина и перевода легальны — проверяем // ровно то, что закрыто: текст описания не встречается ни в одном `title` строки, а выделение // мышью его не копирует — текст закрытого описания остаётся текстом страницы. const titles = await row .locator('[title]') .evaluateAll((nodes) => nodes.map((node) => node.getAttribute('title') ?? '')); // Against the row's ACTUAL description, not a literal from the fixture: a nailed-down word // turns the check silently vacuous the moment the fixture changes. const hidden = (await row.locator('[data-sense]').innerText()).trim(); check( titles.length > 0 && hidden.length > 0 && titles.every((title) => !title.includes(hidden)), `подсказки строки (${String(titles.length)}) не печатают закрытое описание «${hidden}»`, ); const copied = await row.locator('[data-sense]').evaluate((node) => { const range = document.createRange(); range.selectNodeContents(node); const selection = getSelection(); selection?.removeAllRanges(); selection?.addRange(range); const text = selection?.toString() ?? ''; selection?.removeAllRanges(); return text; }); check(copied === '', `выделение закрытого описания ничего не копирует (взято: «${copied}»)`); // Узкая панель роняет столбцы, а не сжимает их: «Тип» приходит только на широкой. // Описание закрыто у ВСЕХ строк, а не у выбранных: спойлер — свойство колонки. // Увести и курсор, и ФОКУС: иначе раскрытой осталась бы строка, на которой они стоят. await page.mouse.move(0, 0); await page.getByRole('searchbox', { name: 'Поиск по банку' }).focus(); await page.waitForTimeout(250); const senses = await table .locator('[role="row"] [data-sense]') .evaluateAll((nodes) => nodes.map((node) => getComputedStyle(node).filter !== 'none')); check( senses.length > 3 && senses.every(Boolean), `описание закрыто во всех ${String(senses.length)} строках, а не в отдельных`, ); const rows = () => table.getByRole('row').count(); const all = await rows(); await page.getByRole('radio', { name: /^имя/ }).click(); const names = await rows(); check(names < all, `фильтр по типу сужает таблицу (${String(all)} → ${String(names)})`); check( (await table.getByRole('columnheader', { name: 'Тип' }).count()) === 0, 'на вкладке одного типа столбец «Тип» уходит: он повторял бы её название в каждой строке', ); await shot('bank-kind'); // ⚠ Every frame of the return, not just where it settles: a hidden column whose width was // dropped comes back as a plain share for one frame — the neighbour jumped 13px. await page.evaluate(() => { /** @type {string[]} */ const seen = []; Object.assign(window, { __layouts: seen }); const tick = () => { seen.push( [...document.querySelectorAll('aside [role="columnheader"]')] .map((head) => ({ x: head.getBoundingClientRect().left, head })) .sort((a, b) => a.x - b.x) .map( ({ head }) => `${head.textContent}=${Math.round(head.getBoundingClientRect().width)}`, ) .join(' '), ); if (seen.length < 90) requestAnimationFrame(tick); }; requestAnimationFrame(tick); }); await page.getByRole('radio', { name: /^все/ }).click(); await page.waitForTimeout(1600); check( await table.getByRole('columnheader', { name: 'Тип' }).isVisible(), 'на «все» столбец «Тип» возвращается — там он единственный способ увидеть тип строки', ); const layouts = await page.evaluate(() => { const seen = /** @type {string[]} */ (Reflect.get(window, '__layouts')); return [...new Set(seen.filter((row) => row.split(' ').length === 4))]; }); check( layouts.length === 1, `возврат на «все» не дёргает столбцы: раскладок за 90 кадров ${layouts.length} (${layouts.join(' | ')})`, ); await page.getByRole('searchbox', { name: 'Поиск по банку' }).fill('цикада'); // Ждём УСТОЙЧИВОСТИ (перерисовка таблицы отложена), а утверждает число сама проверка. await page.waitForTimeout(700); await shot('bank-search'); check((await rows()) === 2, `поиск оставил заголовок и одну строку (всего ${await rows()})`); check( await panel.getByText('показано 1 из 60', { exact: false }).isVisible(), 'подвал говорит, сколько строк осталось от банка после сужения', ); // Nothing found: the table KEEPS its head, and the message stands right under it rather than at // the foot of the panel (an empty collection makes the library reserve the whole height). await page.getByRole('searchbox', { name: 'Поиск по банку' }).fill('щщщ'); await page.waitForTimeout(700); await shot('bank-empty'); const nothing = await panel.evaluate((node) => { const head = node.querySelector('[role="columnheader"]'); const message = node.querySelector('[role="grid"]')?.parentElement?.querySelector('p'); return { heads: node.querySelectorAll('[role="columnheader"]').length, rows: node.querySelectorAll('[role="grid"] [role="row"]').length, gap: Math.round( (message?.getBoundingClientRect().top ?? 0) - (head?.getBoundingClientRect().bottom ?? 0), ), }; }); check( nothing.heads === 4 && nothing.rows === 1, `на пустой выдаче остаются шапка и её ${String(nothing.heads)} столбца, строк данных нет`, ); check( nothing.gap >= 0 && nothing.gap < 40, `сообщение стоит сразу под шапкой, а не в низу панели (отступ ${String(nothing.gap)}px)`, ); await page.getByRole('searchbox', { name: 'Поиск по банку' }).fill(''); await page.waitForTimeout(700); await audit(page, 'таблица банка'); }, // Перформанс таблицы на длинном хвосте: 1200 терминов, прокрутка и поиск. Числа печатаются // в отчёт; пороги грубые и ловят обвал, а не колебания (замер, а не вера). /** @param {Page} page @param {Shot} shot */ async perf(page, shot) { await open(page, '/scale'); const panel = page.locator('aside'); await panel.getByRole('tab', { name: 'Банк' }).click(); const table = panel.getByRole('grid', { name: 'Термины банка' }); const scroll = await table.evaluate(async (node) => { const startedAt = node.scrollTop; /** @type {number[]} */ const frames = []; let last = performance.now(); let running = true; const tick = (/** @type {number} */ now) => { frames.push(now - last); last = now; if (running) requestAnimationFrame(tick); }; requestAnimationFrame(tick); for (let step = 0; step < 40; step += 1) { node.scrollTop += 400; await new Promise((done) => requestAnimationFrame(done)); } running = false; frames.sort((a, b) => a - b); return { median: Math.round(frames[Math.floor(frames.length / 2)] ?? 0), worst: Math.round(frames.at(-1) ?? 0), rows: node.querySelectorAll('[role="row"]').length, nodes: node.querySelectorAll('*').length, // Пройденный путь: без него «дешёвые кадры» и «прокрутка не сдвинулась» неразличимы, // и любая поломка делала бы замер ЗЕЛЕНЕЕ. travelled: node.scrollTop - startedAt, }; }); await shot('perf-scrolled'); console.log( ` прокрутка 1200 терминов: кадр медиана ${scroll.median}мс, худший ${scroll.worst}мс; ` + `в DOM ${scroll.rows} строк / ${scroll.nodes} узлов`, ); check(scroll.travelled > 5000, `таблица реально прокрутилась на ${scroll.travelled}px`); check(scroll.rows > 10, `при этом строки отрисованы (${scroll.rows}) — мерили не пустоту`); check(scroll.median <= 34, `медиана кадра при прокрутке ${scroll.median}мс (порог 34)`); check(scroll.rows <= 80, `в DOM держится ${scroll.rows} строк из 1200 — виртуализация жива`); // Две РАЗНЫЕ величины, и путать их нельзя: отклик поля — то, что человек чувствует пальцами, // а оседание таблицы отстаёт намеренно (useDeferredValue), чтобы ввод не ждал коллекцию. const search = page.getByRole('searchbox', { name: 'Поиск по банку' }); await search.click(); const before = await table.getByRole('row').count(); const rowsSelector = '[role="grid"] [role="row"]'; const typed = Date.now(); // Запрос обязан РЕЗКО сузить выдачу: «фан» есть почти в каждом термине этой фикстуры, // и число строк в DOM не менялось бы — замер мерил бы ожидание, а не перерисовку. await page.keyboard.type('юэф'); await page.waitForFunction( () => /** @type {HTMLInputElement | null} */ (document.querySelector('input[type="search"]')) ?.value === 'юэф', null, { timeout: 5000 }, ); // На символ, а не на всю фразу: порог должен быть про ощущение от КЛАВИШИ. const echo = Math.round((Date.now() - typed) / 3); await page.waitForFunction( ({ was, selector }) => document.querySelectorAll(selector).length < was, { was: before, selector: rowsSelector }, { timeout: 5000 }, ); const settled = Date.now() - typed; console.log( ` поиск по 1200 терминам: поле отвечает за ${echo}мс, таблица оседает за ${settled}мс`, ); // Порог из замера, а не из головы: до починки было ~150 мс на символ (перерисовывалась вся // оболочка), после — 40–45. 60 отделяет одно от другого и терпит шум окружения. check(echo <= 60, `поле ввода отвечает за ${echo}мс на символ (порог 60)`); check(settled <= 700, `таблица оседает за ${settled}мс (порог 700)`); // Драг панели с открытым банком — то, на что владелец пожаловался словами «лагает пиздец». await search.fill(''); const drag = await dragFrames(page); console.log( ` драг панели с банком: медиана ${drag.median}мс, p90 ${drag.p90}мс, худший ${drag.worst}мс, ` + `кадров дольше 50мс — ${drag.janky} из ${drag.frames}`, ); check(drag.moved > 100, `панель в ходе драга реально ездила (на ${drag.moved}px в пике)`); check(drag.p90 <= 34, `p90 кадра при драге ${drag.p90}мс (порог 34)`); check(drag.janky <= 3, `длинных кадров за драг ${drag.janky} (порог 3)`); }, // Замечания 11, 12, 4: три модальных окна вместо вкладок-заглушек и второй кнопки поиска. /** @param {Page} page @param {Shot} shot */ async overlays(page, shot) { await open(page, '/showcase'); // ⚠ Всё ищется ВНУТРИ окна. Из-за keep-alive невыбранные панели остаются в DOM (в этом // и смысл Ф-19), поэтому глобальный локатор по роли находит скрытую строку соседней // вкладки и ждёт её видимости до таймаута. const modal = page.getByRole('dialog'); await page.getByRole('button', { name: 'Настройки' }).click(); await shot('modal-settings'); check(await modal.isVisible(), 'настройки открылись модальным окном'); await audit(page, 'модальное окно настроек'); await page.keyboard.press('Escape'); await page.getByRole('button', { name: 'Добавить книгу' }).click(); await modal.locator('input[type="file"]').setInputFiles({ name: 'gu-zhen-ren.txt', mimeType: 'text/plain', buffer: Buffer.from('пример'), }); await shot('modal-add-book'); check( await modal.getByText('название определит разбор файла', { exact: false }).isVisible(), 'пустое поле названия честно говорит, что название даст разбор файла', ); await modal.getByRole('textbox').last().fill('Гу Чжэньжэнь'); check( await modal.getByText('Название задано вручную', { exact: false }).isVisible(), 'введённое руками название отличается от авто-разбора', ); await page.keyboard.press('Escape'); await page.getByRole('button', { name: 'Перейти к разделу или термину' }).click(); await page.keyboard.type('Аптека'); await shot('modal-goto'); const results = modal.locator('[role="option"]'); check( (await results.count()) === 1 && (await results.first().innerText()).includes('Аптека'), 'палитра перехода находит ровно тот раздел, что назван', ); await results.first().click(); // ⚠ Считать вкладки предпросмотра тут бессмысленно: одна такая на витрине открыта всегда, // и проверка проходила бы, даже если выбор в палитре не делал НИЧЕГО. Спрашиваем ИМЯ. check( (await page.locator('[role="tab"][data-preview="true"]').innerText()).includes('Аптека'), 'выбранный в палитре раздел и открылся — вкладкой предпросмотра', ); // Escape закрывает палитру с ПЕРВОГО раза: примитив поиска первым нажатием чистит строку. await page.getByRole('button', { name: 'Перейти к разделу или термину' }).click(); await page.keyboard.type('Аптека'); await page.keyboard.press('Escape'); check(!(await modal.isVisible()), 'Escape закрывает палитру с первого нажатия'); // ⚠ Цена того фикса: перехват Escape не даёт полю очиститься самому, и палитра открывалась // со СТАРЫМ запросом — следующая буква дописывалась к нему (нашло адверсариальное ревью). await page.getByRole('button', { name: 'Перейти к разделу или термину' }).click(); check( (await modal.getByRole('searchbox').inputValue()) === '', 'открытая заново палитра начинает с чистой строки', ); await page.keyboard.press('Escape'); // Просьба палитры сильнее фильтра типа: иначе она приводит в банк и показывает пустоту. const panel = page.locator('aside'); await panel.getByRole('tab', { name: 'Банк' }).click(); await panel.getByRole('radio', { name: /^место/ }).click(); await page.getByRole('button', { name: 'Перейти к разделу или термину' }).click(); await page.keyboard.type('Фан Юань'); await modal.locator('[role="option"]').first().click(); await page.waitForTimeout(700); const table = panel.getByRole('grid', { name: 'Термины банка' }); check( (await table.getByRole('row').filter({ hasText: 'Фан Юань' }).count()) === 1, 'выбранный в палитре термин виден в банке, хотя стоял фильтр другого типа', ); // Тот же термин, попрошенный ВТОРОЙ раз, — тоже просьба. await panel.getByRole('searchbox', { name: 'Поиск по банку' }).fill('щщщ'); await page.getByRole('button', { name: 'Перейти к разделу или термину' }).click(); await page.keyboard.type('Фан Юань'); await modal.locator('[role="option"]').first().click(); await page.waitForTimeout(700); check( (await table.getByRole('row').filter({ hasText: 'Фан Юань' }).count()) === 1, 'повторная просьба о том же термине срабатывает так же', ); }, }; /** * Drags the right separator through a list of offsets from where it started, then lets go. * @param {Page} page @param {number[]} offsets */ async function pull(page, offsets) { const separator = page.locator('[role="separator"]').nth(1); const box = await separator.boundingBox(); if (!box) throw new Error('правый разделитель не найден'); const y = box.y + box.height / 2; await page.mouse.move(box.x + box.width / 2, y); await page.mouse.down(); for (const x of offsets) await page.mouse.move(box.x + x, y, { steps: 10 }); await page.mouse.up(); await page.waitForTimeout(300); } /** * Драг правого разделителя туда-обратно с замером кадров. Меряется ИМЕННО то, на что жалуется * человек: не абстрактный рендер, а перетаскивание окна с открытой таблицей. * @param {Page} page */ async function dragFrames(page) { const panel = page.locator('aside'); const widthBefore = (await panel.boundingBox())?.width ?? 0; await page.evaluate(() => { /** @type {{frames: number[], last: number, running: boolean}} */ const state = { frames: [], last: performance.now(), running: true }; Object.assign(window, { __frames: state }); const tick = (/** @type {number} */ now) => { state.frames.push(now - state.last); state.last = now; if (state.running) requestAnimationFrame(tick); }; requestAnimationFrame(tick); }); const separator = page.locator('[role="separator"]').nth(1); const box = await separator.boundingBox(); if (!box) throw new Error('правый разделитель не найден'); const y = box.y + box.height / 2; await page.mouse.move(box.x + box.width / 2, y); await page.mouse.down(); let widthPeak = widthBefore; for (const x of [-60, -140, -220, -300, -220, -140, -60, 0]) { await page.mouse.move(box.x + x, y, { steps: 8 }); // Ширину снимаем В ХОДЕ драга: к концу мышь возвращается на место, и сравнение «до/после» // показало бы ноль — то есть замер прошёл бы и на намертво застрявшей панели. const width = (await panel.boundingBox())?.width ?? 0; if (width > widthPeak) widthPeak = width; } await page.mouse.up(); const stats = await page.evaluate(() => { const state = /** @type {{frames: number[], running: boolean}} */ ( Reflect.get(window, '__frames') ); state.running = false; const frames = [...state.frames].sort((a, b) => a - b); return { median: Math.round(frames[Math.floor(frames.length / 2)] ?? 0), p90: Math.round(frames[Math.floor(frames.length * 0.9)] ?? 0), worst: Math.round(frames.at(-1) ?? 0), // Человек чувствует ДЛИННЫЕ кадры, а не медиану: она держалась 17 мс и там, где драг // рвался в клочья. Считаем их штуками. janky: frames.filter((frame) => frame > 50).length, frames: frames.length, }; }); return { ...stats, moved: Math.round(widthPeak - widthBefore) }; } /** * Whether the description is covered. The spoiler is `filter: blur()`: a filter is applied AFTER * the element is drawn and only an ancestor can cut it, whereas `text-shadow` is the element's own * ink and its own `overflow` cuts it at the glyphs (owner, 10.08: "square on the left"). * @param {Locator} sense */ const isCovered = (sense) => sense.evaluate((node) => getComputedStyle(node).filter !== 'none'); /** @param {Locator} separator */ const handleStyle = (separator) => separator .locator('span') .first() .evaluate((node) => { const style = getComputedStyle(node); return { opacity: style.opacity, background: style.backgroundColor, height: style.height }; }); /** @param {Page} page @param {string} route */ async function open(page, route) { await page.goto(`${origin}${route}`, { waitUntil: 'load' }); await page.waitForFunction(() => document.documentElement.dataset.screen === 'ready', null, { timeout: 15_000, }); await page.evaluate(() => document.fonts.ready); } const requested = process.argv.slice(2); const names = requested.length > 0 ? requested : Object.keys(scenes); const unknown = names.filter((name) => !(name in scenes)); if (unknown.length > 0) { throw new Error( `Нет такого сценария: ${unknown.join(', ')}. Известные: ${Object.keys(scenes).join(', ')}`, ); } await mkdir(shotsDir, { recursive: true }); await build({ logLevel: 'warn' }); const server = await preview({ preview: { open: false } }); const origin = server.resolvedUrls?.local[0]?.replace(/\/$/, ''); if (!origin) throw new Error('vite preview не отдал локальный адрес'); const browser = await chromium.launch(); const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2, }); const page = await context.newPage(); for (const name of names) { console.log(`\n${name}`); /** @param {string} file */ const shot = (file) => page.screenshot({ path: resolve(shotsDir, `${file}.png`) }); await scenes[name]?.(page, shot); } await browser.close(); await server.close(); console.log('\nвсе сценарии прошли');