textmachine/frontend/scripts/scenes.mjs

894 lines
49 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.

// Сценарии ИНТЕРАКЦИЙ: клик → кадр → проверка состояния. Статичный снимок половину пака 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<unknown>} 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<string, (page: Page, shot: Shot) => Promise<void>>} */
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 мс на символ (перерисовывалась вся
// оболочка), после — 4045. 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все сценарии прошли');