894 lines
49 KiB
JavaScript
894 lines
49 KiB
JavaScript
// Сценарии ИНТЕРАКЦИЙ: клик → кадр → проверка состояния. Статичный снимок половину пака 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 мс на символ (перерисовывалась вся
|
||
// оболочка), после — 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все сценарии прошли');
|