1272 lines
58 KiB
JavaScript
1272 lines
58 KiB
JavaScript
// Scenarios of INTERACTION: click → frame → check of the state. Half of pack S3.5 a still frame
|
||
// does not accept — tabs, the drag handle, modals and spoilers exist only in motion.
|
||
//
|
||
// node scripts/scenes.mjs every scenario, frames into .shots/scenes/
|
||
// node scripts/scenes.mjs tabs drag only the named ones
|
||
//
|
||
// A failing scenario fails the command: this is acceptance, not a demonstration.
|
||
|
||
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');
|
||
|
||
// The words a scene clicks are the product's own: interface wording comes from the message
|
||
// catalogue, chapter headings and bank terms from the fixtures the fake server answers with. A copy
|
||
// pasted in here goes stale in silence, and a scene looking for a word nobody prints any more finds
|
||
// nothing.
|
||
//
|
||
// ⚠ The specifier is BUILT rather than written out: Node resolves a module only with its `.ts`
|
||
// extension, while `tsc` rejects that extension until `allowImportingTsExtensions` is on — and
|
||
// tsconfig.json is outside this zone. The JSDoc types below go through the extensionless path,
|
||
// which only TypeScript reads, so the keys and the shape of the fixtures are still checked.
|
||
const load = (/** @type {string} */ path) => import(new URL(path, import.meta.url).href);
|
||
|
||
/** @typedef {import('../src/api/schema').components['schemas']} Schemas */
|
||
/** @typedef {import('../src/i18n/ru').MessageKey} MessageKey */
|
||
|
||
/** @type {Record<MessageKey, string>} */
|
||
const ru = (await load('../src/i18n/ru.ts')).ru;
|
||
/** @type {{ book: Schemas['Book'], chapters: Schemas['Chapter'][], notes: Schemas['Note'][] }} */
|
||
const { book, chapters, notes } = await load('../src/mock/book.ts');
|
||
/** @type {{ terms: Schemas['BankTerm'][], bankTerms: Schemas['BankTerm'][] }} */
|
||
const { terms, bankTerms } = await load('../src/mock/bank.ts');
|
||
/** @type {{ scaleTerms: Schemas['BankTerm'][] }} */
|
||
const { scaleTerms } = await load('../src/mock/scale.ts');
|
||
|
||
/** @typedef {import('playwright').Page} Page */
|
||
/** @typedef {import('playwright').Locator} Locator */
|
||
/** @typedef {(name: string) => Promise<unknown>} Shot */
|
||
|
||
/**
|
||
* Interface wording by catalogue key — a scene looks for exactly what the screen prints. An unknown
|
||
* key throws: a locator built out of `undefined` matches ANY node of its role, and the scene would
|
||
* go green having checked nothing.
|
||
* @param {MessageKey} key @param {Record<string, string | number>} [variables]
|
||
*/
|
||
function say(key, variables = {}) {
|
||
const line = ru[key];
|
||
if (line === undefined) throw new Error(`no such key in the message catalogue: ${key}`);
|
||
return line.replace(/\{(\w+)\}/g, (whole, name) => String(variables[name] ?? whole));
|
||
}
|
||
|
||
/**
|
||
* The heading a chapter carries in the fixture: the tree prints it, the scene clicks it. By id and
|
||
* not by position — the fixture deliberately keeps a chapter with no heading at all and one with no
|
||
* number, and both would shift under an index.
|
||
* @param {string} id
|
||
*/
|
||
function heading(id) {
|
||
const found = chapters.find((row) => row.id === id)?.heading;
|
||
if (found === null || found === undefined || found === '') {
|
||
throw new Error(`fixture chapter ${id} carries no heading`);
|
||
}
|
||
return found;
|
||
}
|
||
|
||
/**
|
||
* The translated side of a bank term in the fixture. Terms are looked up by id rather than repeated
|
||
* as a word: the fixture owns the wording, and a pasted copy goes stale in silence.
|
||
* @param {Schemas['BankTerm'][]} bank @param {string} id
|
||
*/
|
||
function dstOf(bank, id) {
|
||
const dst = bank.find((row) => row.id === id)?.dst;
|
||
if (dst === undefined || dst === '') throw new Error(`fixture term ${id} carries no translation`);
|
||
return dst;
|
||
}
|
||
|
||
/** @param {boolean} condition @param {string} what */
|
||
function check(condition, what) {
|
||
if (!condition) throw new Error(`scenario failed: ${what}`);
|
||
console.log(` ✓ ${what}`);
|
||
}
|
||
|
||
/**
|
||
* An axe run over the OPEN state. `npm run shot` checks routes only, that is, a screen that opens on
|
||
* a click (the bank table, the modals) never saw the accessibility gate at all.
|
||
* The policy is the same as in shot.mjs: contrast goes into the report, the rest fails the scenario.
|
||
* @param {Page} page @param {string} what
|
||
*/
|
||
async function audit(page, what) {
|
||
// Let the animations play out. Bought by the acceptance run of 10.08: axe, started in the middle
|
||
// of the 220ms appearance of the card, was measuring SEMI-TRANSPARENT text and printing contrast
|
||
// nodes that are not there on the settled frame.
|
||
await settle(page);
|
||
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(` contrast (accepted as is): ${v.nodes.length} node(s)`);
|
||
}
|
||
if (blocking.length > 0) {
|
||
for (const v of blocking) {
|
||
console.error(` ✖ ${v.id}: ${v.help} — ${v.nodes.length} node(s)`);
|
||
// The node is printed right away: without it a "single violation" has to be hunted by hand.
|
||
for (const node of v.nodes)
|
||
console.error(` ${node.target.join(' ')} — ${node.html.slice(0, 160)}`);
|
||
}
|
||
throw new Error(`Accessibility: ${blocking.length} violation(s) — ${what}`);
|
||
}
|
||
console.log(` ✓ accessibility gate: ${what}`);
|
||
}
|
||
|
||
/** @type {Record<string, (page: Page, shot: Shot) => Promise<void>>} */
|
||
const scenes = {
|
||
// Remark 2 plus the prompt's acceptance: five single clicks — one preview tab.
|
||
/** @param {Page} page @param {Shot} shot */
|
||
async tabs(page, shot) {
|
||
await open(page, '/showcase');
|
||
const tabs = page.locator('[role="tab"]');
|
||
// Tree rows are looked for INSIDE the left panel: the same chapter names stand in the summary
|
||
// of notes on the right as well, and with no area to search in the locator points at two things
|
||
// at once.
|
||
/** @param {string} name */
|
||
const chapter = (name) => page.locator('nav').getByText(name, { exact: true });
|
||
for (const name of [
|
||
heading('ch_1'),
|
||
heading('ch_3'),
|
||
heading('ch_5'),
|
||
heading('ch_7'),
|
||
heading('ch_6'),
|
||
]) {
|
||
await chapter(name).click();
|
||
}
|
||
await shot('tabs-preview');
|
||
check(
|
||
(await page.locator('[role="tab"][data-preview="true"]').count()) === 1,
|
||
'after five single clicks exactly one preview tab',
|
||
);
|
||
const before = await tabs.count();
|
||
|
||
await chapter(heading('ch_7')).dblclick();
|
||
check(
|
||
(await page.locator('[role="tab"][data-preview="true"]').count()) === 0,
|
||
'a double click on a chapter pins the tab — no preview is left',
|
||
);
|
||
|
||
await chapter(heading('ch_8')).click();
|
||
await shot('tabs-pinned');
|
||
check(
|
||
(await tabs.count()) === before + 1 &&
|
||
(await page.locator('[role="tab"][data-preview="true"]').count()) === 1,
|
||
'the next single click opens a NEW preview tab beside the pinned one',
|
||
);
|
||
|
||
// A double click on the tab itself — the second way of pinning, from the VS Code model.
|
||
await page.locator('[role="tab"][data-preview="true"]').dblclick();
|
||
check(
|
||
(await page.locator('[role="tab"][data-preview="true"]').count()) === 0,
|
||
'a double click on the preview tab pins it',
|
||
);
|
||
|
||
// ⚠ A double click PAST a chapter row must pin nothing. The previous implementation took the
|
||
// "current selection", and neither the book nor empty space moves the selection — so somebody
|
||
// else's tab got pinned (found by an adversarial review with a measurement, not by reasoning).
|
||
await chapter(heading('ch_10')).click();
|
||
const pinnedBefore = await tabs.count();
|
||
await page.locator('nav').getByText(book.title, { exact: true }).dblclick();
|
||
check(
|
||
(await tabs.count()) === pinnedBefore &&
|
||
(await page.locator('[role="tab"][data-preview="true"]').count()) === 1,
|
||
'a double click on the row of the BOOK does not touch the tabs',
|
||
);
|
||
|
||
// The keyboard twin of the double click: Enter on the selected row pins it.
|
||
await page.keyboard.press('Enter');
|
||
check(
|
||
(await page.locator('[role="tab"][data-preview="true"]').count()) === 1,
|
||
'Enter on the row of the book does not touch the tabs either',
|
||
);
|
||
await chapter(heading('ch_10')).click();
|
||
await page.keyboard.press('Enter');
|
||
check(
|
||
(await page.locator('[role="tab"][data-preview="true"]').count()) === 0,
|
||
'Enter on the selected chapter row pins the tab',
|
||
);
|
||
|
||
// ⚠ 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(heading('ch_9')).boundingBox();
|
||
if (!row) throw new Error('chapter row not found');
|
||
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,
|
||
'opening a chapter does not flash a waiting card before the text',
|
||
);
|
||
|
||
// Second-round remark 4: a short title was clipped before its longer neighbour, because the tabs
|
||
// shared the shortage of space in proportion to their width. The row scrolls now, and clipping
|
||
// happens ONLY to a tab that has run into its own limit.
|
||
const clipped = await page.locator('[role="tab"]').evaluateAll((nodes) =>
|
||
nodes
|
||
.map((node) => {
|
||
const title = node.querySelector('span');
|
||
// ⚠ Without this the guard would go green in silence: let the title stop being a `span`,
|
||
// and `title` becomes null, `cut` becomes false, and "none is clipped" a truth about
|
||
// nothing.
|
||
if (!title) throw new Error('the tab has no span title — the clipping guard went blind');
|
||
const max = parseFloat(getComputedStyle(node).maxWidth);
|
||
return {
|
||
text: title.textContent ?? '',
|
||
cut: title.scrollWidth > title.clientWidth + 1,
|
||
atMax: node.getBoundingClientRect().width >= max - 1,
|
||
};
|
||
})
|
||
.filter((tab) => tab.cut && !tab.atMax),
|
||
);
|
||
check(
|
||
clipped.length === 0,
|
||
`no tab is clipped without having run into its width limit (clipped: ${clipped.length})`,
|
||
);
|
||
|
||
// Acceptance finding 8.2: the selected row has a blue ground and the state is PERMANENT (an open
|
||
// chapter is selected always), while its content is drawn in quiet greys. axe does not catch
|
||
// this — the contrast of non-text it does not check at all, and the row with the counter it
|
||
// never saw.
|
||
// The row picked is the one that has EVERYTHING: the notes counter, the icon and the progress
|
||
// track — otherwise the guard would silently leave half of the pairs unchecked.
|
||
await chapter(heading('ch_2')).click();
|
||
const faint = await contrastInSelectedRow(page);
|
||
check(
|
||
faint.length === 0,
|
||
`everything on the selected row is readable: ${faint.map((part) => `${part.what} ${part.ratio}:1 < ${part.floor}`).join(' · ') || 'not a single pair below the floor'}`,
|
||
);
|
||
},
|
||
|
||
// Ф-19: switching a tab no longer unmounts the panel, the scroll lives on.
|
||
/** @param {Page} page @param {Shot} shot */
|
||
async scroll(page, shot) {
|
||
await open(page, '/scale');
|
||
const bank = page.locator(`[role="grid"][aria-label="${say('bank.tableLabel')}"]`);
|
||
await bank.evaluate((node) => node.scrollTo(0, 9000));
|
||
const before = await bank.evaluate((node) => node.scrollTop);
|
||
check(before > 8000, `the bank scroll came to rest at ${String(before)}px`);
|
||
|
||
await page.getByRole('tab', { name: say('about.tab') }).click();
|
||
await page.getByRole('tab', { name: say('bank.tab') }).click();
|
||
await shot('scroll-kept');
|
||
const after = await bank.evaluate((node) => node.scrollTop);
|
||
check(
|
||
after === before,
|
||
`after leaving for the neighbouring tab and coming back the offset is the same (${String(after)})`,
|
||
);
|
||
},
|
||
|
||
// Remark 18: the resize strip does not stick after the drag is released.
|
||
/** @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('separator not found');
|
||
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);
|
||
// The numbers come from vojo (style.css.ts): 0.55 in the drag and the pill growing to 48px.
|
||
check(
|
||
dragging.opacity === '0.55',
|
||
`in the drag the pill shows through at ${dragging.opacity}, as in vojo`,
|
||
);
|
||
check(
|
||
dragging.height === '48px',
|
||
`in the drag the pill grows to ${dragging.height}, as in 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',
|
||
`after the release the pill went out (opacity ${released.opacity}), the strip did not stick`,
|
||
);
|
||
check(
|
||
(await page.locator('[role="separator"]').first().getAttribute('data-separator')) === 'focus',
|
||
'the library HOLDS data-separator="focus" meanwhile — sticking is cured by style, not by luck',
|
||
);
|
||
},
|
||
|
||
// Remarks 7, 8, 13, 20: the context panel — the summary of notes, the bank with spoilers, an
|
||
// honest state instead of a stub button.
|
||
/** @param {Page} page @param {Shot} shot */
|
||
async context(page, shot) {
|
||
await open(page, '/showcase');
|
||
const panel = page.locator('aside');
|
||
|
||
await panel.getByRole('tab', { name: say('notes.tab') }).click();
|
||
await shot('context-notes');
|
||
// Looked for in ITS OWN list: with keep-alive the neighbouring tab is in the DOM too, and
|
||
// `[role="option"]` over the whole panel counts the rows of the bank along with it.
|
||
const summary = panel.locator(
|
||
`[role="listbox"][aria-label="${say('notes.listLabel')}"] [role="option"]`,
|
||
);
|
||
// Exactly as many as there are notes in the book's fixture. The check is not cosmetic: a row key
|
||
// built out of the chapter collapsed three notes of one chapter into a single row.
|
||
check(
|
||
(await summary.count()) === notes.length,
|
||
'the summary shows ALL the notes of the book, several in one chapter included',
|
||
);
|
||
await summary.first().click();
|
||
check(
|
||
(await page.locator('[role="tab"][data-preview="true"]').count()) === 1,
|
||
'a row of the summary opens its chapter as a preview tab',
|
||
);
|
||
},
|
||
|
||
// Second-round remark 7: the bank is a TABLE in the right panel (not a tab in the centre), with
|
||
// columns, a search, a filter by kind and a spoiler on the description, not on the translation.
|
||
/** @param {Page} page @param {Shot} shot */
|
||
async bank(page, shot) {
|
||
await open(page, '/showcase');
|
||
const panel = page.locator('aside');
|
||
await panel.getByRole('tab', { name: say('bank.tab') }).click();
|
||
await shot('bank-table');
|
||
const table = panel.getByRole('grid', { name: say('bank.tableLabel') });
|
||
check(await table.isVisible(), 'the bank lives in the right panel and is shown as a table');
|
||
|
||
check(
|
||
(await page.getByRole('grid', { name: say('bank.tableLabel') }).count()) === 1,
|
||
'there is exactly one bank table in the application — no duplicate in the centre',
|
||
);
|
||
// The WHOLE set is asserted at once: a check for "no «state» column" would pass on a «signature»
|
||
// column just as well, that is, it would guard a word and not the make-up of the screen.
|
||
// Compared in lower case: the headings are set in small caps by CSS means, and `innerText`
|
||
// returns them already upper-cased — what has to be checked is the SET of columns, not the
|
||
// typesetting.
|
||
const columns = (await table.getByRole('columnheader').allInnerTexts()).map((name) =>
|
||
name.toLowerCase(),
|
||
);
|
||
const expected = [
|
||
say('bank.columnTerm'),
|
||
say('bank.columnTranslation'),
|
||
say('bank.columnKind'),
|
||
say('bank.columnSense'),
|
||
].map((name) => name.toLowerCase());
|
||
check(
|
||
JSON.stringify(columns) === JSON.stringify(expected),
|
||
`the columns of the table are exactly those: ${columns.join(' · ')} — no signing state among them`,
|
||
);
|
||
|
||
// 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; // the pointer is still off — the scroll has not settled
|
||
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,
|
||
`a scrolled row does not show through the header (on top ${String(header?.role)}, ground opacity ${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 columnSpread(table);
|
||
// 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}: the columns fill the table whole (${spread.sum} of ${spread.content}, window ${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}: the table does not run past the edge of the panel (content ${spread.content}, window ${spread.grid})`,
|
||
);
|
||
return spread.cut;
|
||
};
|
||
/** @param {string} what @param {string[]} cut */
|
||
const fits = (what, cut) =>
|
||
check(
|
||
cut.length === 0,
|
||
`${what}: the columns sized by content show it whole (clipped: ${cut.join(' · ') || 'nothing'})`,
|
||
);
|
||
fits('at its own width', await filled('at its own width'));
|
||
// 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('the panel squeezed to its stop');
|
||
await pull(page, [-200]);
|
||
fits(
|
||
'after being squeezed to the stop and let back out',
|
||
await filled('after being squeezed to the stop and let back out'),
|
||
);
|
||
|
||
// The spoiler covers the DESCRIPTION, the translation stays readable.
|
||
const spoilered = dstOf(terms, 't_13');
|
||
const row = table.getByRole('row').filter({ hasText: spoilered });
|
||
// The colour is taken apart into numbers rather than compared with a string: a literal colour in
|
||
// the code is forbidden by the gate, and the comparison has to be against the property anyway,
|
||
// not against the way the browser prints it.
|
||
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,
|
||
`the description is covered by a real blur and clipped by nothing (${covered.blur}, clipping ${String(covered.clips)})`,
|
||
);
|
||
check(
|
||
await row.getByText(spoilered, { exact: true }).isVisible(),
|
||
'the translation itself is visible meanwhile',
|
||
);
|
||
await row.locator('[data-sense]').hover();
|
||
await page.waitForTimeout(250); // the colour transition is 140ms — else we read mid-animation
|
||
check(
|
||
!(await isCovered(row.locator('[data-sense]'))),
|
||
'hovering the cell reveals the description',
|
||
);
|
||
|
||
// A blur covers nothing from a screen reader, so the description is taken out of the
|
||
// accessibility tree, and what reveals it is a toggle — the only way that works for the mouse,
|
||
// and for the keyboard, and for the reader.
|
||
check(
|
||
(await row.locator('[data-sense]').getAttribute('aria-hidden')) === 'true',
|
||
'while it is not revealed, the description is out of the accessibility tree',
|
||
);
|
||
await panel.getByRole('button', { name: say('bank.showSenses') }).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',
|
||
`the toggle reveals the descriptions for the eyes and for the reader alike (${JSON.stringify(revealed)})`,
|
||
);
|
||
await panel.getByRole('button', { name: say('bank.hideSenses') }).click();
|
||
await page.waitForTimeout(250);
|
||
|
||
// Walking with the arrows does NOT reveal the descriptions: the spoiler would otherwise be
|
||
// opened one row per keypress, while the keyboard is served by the toggle above.
|
||
await page.mouse.move(0, 0);
|
||
// The CELL is clicked: `role="row"` itself is a container of zero height, there is nothing on it
|
||
// to click, and a person lands in a cell as well.
|
||
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,
|
||
`walking the rows from the keyboard does not open the descriptions (covered: ${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('),
|
||
`in high-contrast mode the description stays covered (${JSON.stringify(forced)})`,
|
||
);
|
||
await page.emulateMedia({ forcedColors: 'none' });
|
||
await page.waitForTimeout(200);
|
||
|
||
// The spoiler must not leak past the blur. Tooltips on the term and on the translation are
|
||
// legal — what is checked is exactly what is covered: the text of the description occurs in no
|
||
// `title` of the row, and a mouse selection does not copy it — the text of a covered description
|
||
// remains text of the page.
|
||
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)),
|
||
`the tooltips of the row (${String(titles.length)}) do not print the covered description «${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 === '', `selecting a covered description copies nothing (taken: «${copied}»)`);
|
||
|
||
// A narrow panel drops columns instead of squeezing them: the kind column comes only on a wide
|
||
// one. The description is covered on ALL rows, not on the selected ones: the spoiler is a
|
||
// property of the column. Both the cursor and the FOCUS are taken away: the row they stand on
|
||
// would otherwise stay revealed.
|
||
await page.mouse.move(0, 0);
|
||
await page.getByRole('searchbox', { name: say('bank.search') }).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),
|
||
`the description is covered in all ${String(senses.length)} rows, not in single ones`,
|
||
);
|
||
|
||
const rows = () => table.getByRole('row').count();
|
||
const all = await rows();
|
||
await page.getByRole('radio', { name: new RegExp(`^${say('kind.name')}`) }).click();
|
||
const names = await rows();
|
||
check(names < all, `the filter by kind narrows the table (${String(all)} → ${String(names)})`);
|
||
check(
|
||
(await table.getByRole('columnheader', { name: say('bank.columnKind') }).count()) === 0,
|
||
'on a tab of one kind the kind column goes: it would repeat the name of that tab in every row',
|
||
);
|
||
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: new RegExp(`^${say('bank.kindAll')}`) }).click();
|
||
await page.waitForTimeout(1600);
|
||
check(
|
||
await table.getByRole('columnheader', { name: say('bank.columnKind') }).isVisible(),
|
||
'on "all" the kind column comes back — there it is the only way to see the kind of a row',
|
||
);
|
||
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,
|
||
`the return to "all" does not jerk the columns: layouts over 90 frames ${layouts.length} (${layouts.join(' | ')})`,
|
||
);
|
||
await page.getByRole('searchbox', { name: say('bank.search') }).fill(dstOf(terms, 't_9'));
|
||
// We wait for the SETTLING (the table's repaint is deferred), while the number is asserted by
|
||
// the check itself.
|
||
await page.waitForTimeout(700);
|
||
await shot('bank-search');
|
||
check((await rows()) === 2, `the search left the header and one row (${await rows()} in all)`);
|
||
check(
|
||
await panel
|
||
.getByText(say('bank.shown', { shown: 1, total: bankTerms.length }), { exact: false })
|
||
.isVisible(),
|
||
'the foot says how many rows of the bank are left after the narrowing',
|
||
);
|
||
|
||
// 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: say('bank.search') }).fill('zzz');
|
||
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,
|
||
`on an empty result the head and its ${String(nothing.heads)} columns remain, with no data rows`,
|
||
);
|
||
check(
|
||
nothing.gap >= 0 && nothing.gap < 40,
|
||
`the message stands right under the head, not at the foot of the panel (gap ${String(nothing.gap)}px)`,
|
||
);
|
||
await page.getByRole('searchbox', { name: say('bank.search') }).fill('');
|
||
await page.waitForTimeout(700);
|
||
await audit(page, 'the bank table');
|
||
},
|
||
|
||
// Acceptance finding 8.1, kept as a GUARD rather than as a fix: a table mounted inside a hidden
|
||
// keep-alive tab was reported to lose its measured widths. Measured here instead of believed, and
|
||
// it does not happen — a layout query inside a `content-visibility: hidden` subtree forces the
|
||
// layout it needs, so the virtualizer has its rows and `read()` has its columns (probe: 116 cells
|
||
// while hidden, widths 121·122·133·232, identical to the tab that was never left). The scene
|
||
// stays because the scenario is real and nothing else covers it.
|
||
/** @param {Page} page @param {Shot} shot */
|
||
async hidden(page, shot) {
|
||
await page.goto(`${origin}/scale`, { waitUntil: 'load' });
|
||
const panel = page.locator('aside');
|
||
// We leave the bank BEFORE it has answered: the tabs are drawn at once, the data are not.
|
||
await panel.getByRole('tab', { name: say('about.tab') }).click();
|
||
await page.waitForFunction(() => document.documentElement.dataset.screen === 'ready', null, {
|
||
timeout: 15_000,
|
||
});
|
||
await page.evaluate(() => document.fonts.ready);
|
||
|
||
await panel.getByRole('tab', { name: say('bank.tab') }).click();
|
||
await page.waitForTimeout(600);
|
||
await shot('hidden-bank');
|
||
const table = panel.getByRole('grid', { name: say('bank.tableLabel') });
|
||
const hiddenThenShown = await columnSpread(table);
|
||
|
||
// The same table that was never left, for comparison. Checking "nothing is clipped" alone would
|
||
// not do: the fallback the defect would leave behind is equal shares, and equal shares clip
|
||
// nothing in a panel this wide — the guard has to compare the WIDTHS with the ones the same
|
||
// content produces when the tab was visible all along.
|
||
await open(page, '/scale');
|
||
await page.waitForTimeout(600);
|
||
const neverHidden = await columnSpread(
|
||
page.locator('aside').getByRole('grid', { name: say('bank.tableLabel') }),
|
||
);
|
||
check(
|
||
hiddenThenShown.widths.join('·') === neverHidden.widths.join('·'),
|
||
`a table mounted in a hidden tab measures the same as one that was never left ` +
|
||
`(${hiddenThenShown.widths.join('·')} against ${neverHidden.widths.join('·')})`,
|
||
);
|
||
check(
|
||
hiddenThenShown.cut.length === 0,
|
||
`and it clips nothing (${hiddenThenShown.cut.join(' · ') || 'nothing'})`,
|
||
);
|
||
},
|
||
|
||
// Debt S3.6: zoom at 125/150/200% and the narrow panel were measured by hand, and no guard was
|
||
// left. For the layout, browser zoom is the same viewport in CSS pixels: 1440 at 150% gives 960.
|
||
/** @param {Page} page @param {Shot} shot */
|
||
async zoom(page, shot) {
|
||
const base = page.viewportSize() ?? { width: 1440, height: 900 };
|
||
// The panel library persists its layout, so a scene that squeezes the window would hand the
|
||
// next one a shell it never asked for — and `perf`, which runs after this, measures a drag.
|
||
await open(page, '/showcase');
|
||
const stored = await page.evaluate(() => JSON.stringify(localStorage));
|
||
/** @type {{percent: number, width: number, height: number}[]} */
|
||
const steps = [
|
||
{ percent: 125, width: 1152, height: 720 },
|
||
{ percent: 150, width: 960, height: 600 },
|
||
{ percent: 200, width: 720, height: 450 },
|
||
];
|
||
for (const { percent, width, height } of steps) {
|
||
await page.setViewportSize({ width, height });
|
||
await open(page, '/showcase');
|
||
await page
|
||
.locator('aside')
|
||
.getByRole('tab', { name: say('bank.tab') })
|
||
.click();
|
||
await page.waitForTimeout(400);
|
||
await shot(`zoom-${percent}`);
|
||
|
||
const layout = await page.evaluate(() => {
|
||
const footer = document.querySelector('footer');
|
||
const gaps = document.querySelectorAll('[role="separator"]').length;
|
||
return {
|
||
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||
panels: [...document.querySelectorAll('nav, main, aside')].map((node) =>
|
||
Math.round(node.getBoundingClientRect().width),
|
||
),
|
||
edges: [...document.querySelectorAll('nav, main, aside')].map((node) => ({
|
||
left: Math.round(node.getBoundingClientRect().left),
|
||
right: Math.round(node.getBoundingClientRect().right),
|
||
})),
|
||
// How much of the centre its own slot cuts off: the reader's second column goes first.
|
||
centreCut: Math.max(
|
||
0,
|
||
Math.round(
|
||
(document.querySelector('main')?.getBoundingClientRect().right ?? 0) -
|
||
(document.querySelector('aside')?.getBoundingClientRect().left ?? 0) +
|
||
gaps * 0,
|
||
),
|
||
),
|
||
statusCut: [...(footer?.querySelectorAll('span') ?? [])].reduce(
|
||
(worst, span) => Math.max(worst, span.scrollWidth - span.clientWidth),
|
||
0,
|
||
),
|
||
};
|
||
});
|
||
check(
|
||
layout.overflow <= 0,
|
||
`${percent}%: the window does not run sideways (overflow ${layout.overflow}px)`,
|
||
);
|
||
// Not "each is wider than zero" — the library cannot give a panel a negative width, so that
|
||
// form would hold on any layout at all. What is asked is that the three still stand side by
|
||
// side inside the window, in order and without lying on top of one another.
|
||
check(
|
||
layout.edges.length === 3 &&
|
||
layout.edges.every(({ left, right }) => left >= 0 && right <= width + 1) &&
|
||
layout.edges.every(({ left }, index) => left >= (layout.edges[index - 1]?.left ?? 0)),
|
||
`${percent}%: the three panels stand side by side inside the window (${layout.panels.join(' · ')})`,
|
||
);
|
||
// The centre keeps a floor of its own and its CONTENT is cut by it long before the window is:
|
||
// printed rather than asserted, because the floor is the owner's question of interface density
|
||
// (BACKLOG Ф-36/Ф-54) and a gate red by design would only teach people to skip the command.
|
||
console.log(` centre content cut by ${String(layout.centreCut)}px at ${percent}%`);
|
||
// Not "the strip is inside the window" — the footer's bottom is the window's by construction.
|
||
// What zoom actually threatens is the TEXT in it: it is the first thing to be cut.
|
||
check(
|
||
layout.statusCut === 0,
|
||
`${percent}%: the text of the status strip is not cut (${String(layout.statusCut)}px past its box)`,
|
||
);
|
||
const table = page.locator('aside').getByRole('grid', { name: say('bank.tableLabel') });
|
||
const spread = await columnSpread(table);
|
||
// The width check alone would go green on a table that drew nothing (0 <= anything), so the
|
||
// columns are counted first.
|
||
check(
|
||
spread.sum > 0,
|
||
`${percent}%: the bank table is drawn at all (${spread.sum}px of columns)`,
|
||
);
|
||
check(
|
||
spread.content <= spread.grid + 1,
|
||
`${percent}%: the bank table does not run past the edge of the panel (${spread.content} at window ${spread.grid})`,
|
||
);
|
||
await audit(page, `the shell at ${percent}%`);
|
||
}
|
||
|
||
await page.setViewportSize(base);
|
||
await page.evaluate((was) => {
|
||
localStorage.clear();
|
||
for (const [key, value] of Object.entries(JSON.parse(was))) localStorage.setItem(key, value);
|
||
}, stored);
|
||
},
|
||
|
||
// The table's performance on the long tail: 1200 terms, scrolling and search. The numbers are
|
||
// printed into the report; the thresholds are coarse and catch a collapse, not a wobble
|
||
// (measurement, not belief).
|
||
/** @param {Page} page @param {Shot} shot */
|
||
async perf(page, shot) {
|
||
await open(page, '/scale');
|
||
const panel = page.locator('aside');
|
||
await panel.getByRole('tab', { name: say('bank.tab') }).click();
|
||
const table = panel.getByRole('grid', { name: say('bank.tableLabel') });
|
||
|
||
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,
|
||
// The distance travelled: without it "cheap frames" and "the scroll did not move" are
|
||
// indistinguishable, and any breakage would make the measurement GREENER.
|
||
travelled: node.scrollTop - startedAt,
|
||
};
|
||
});
|
||
await shot('perf-scrolled');
|
||
console.log(
|
||
` scrolling 1200 terms: frame median ${scroll.median}ms, worst ${scroll.worst}ms; ` +
|
||
`${scroll.rows} rows / ${scroll.nodes} nodes in the DOM`,
|
||
);
|
||
check(scroll.travelled > 5000, `the table really scrolled by ${scroll.travelled}px`);
|
||
check(
|
||
scroll.rows > 10,
|
||
`and the rows are drawn meanwhile (${scroll.rows}) — we measured no emptiness`,
|
||
);
|
||
check(
|
||
scroll.median <= 34,
|
||
`the median frame while scrolling is ${scroll.median}ms (threshold 34)`,
|
||
);
|
||
check(
|
||
scroll.rows <= 80,
|
||
`${scroll.rows} rows out of 1200 are held in the DOM — the virtualizer is alive`,
|
||
);
|
||
|
||
// Two DIFFERENT quantities, and they must not be confused: the response of the field is what a
|
||
// person feels with their fingers, while the settling of the table lags on purpose
|
||
// (useDeferredValue), so that typing does not wait for the collection.
|
||
const search = page.getByRole('searchbox', { name: say('bank.search') });
|
||
await search.click();
|
||
const before = await table.getByRole('row').count();
|
||
const rowsSelector = '[role="grid"] [role="row"]';
|
||
// The query MUST narrow the result SHARPLY: a single syllable is in almost every term of this
|
||
// fixture, the number of rows in the DOM would not change — the measurement would be timing the
|
||
// wait and not the repaint. A whole translated term is specific enough. Taken out of the fixture
|
||
// BEFORE the stopwatch starts: nothing of ours belongs inside the window being measured.
|
||
const query = dstOf(scaleTerms, 't_5');
|
||
|
||
// FIVE rounds and the MEDIAN, not one round divided by its length. The quantity is the same —
|
||
// milliseconds per keystroke — but a single round is an instrument the machine's own load moves:
|
||
// measured 43·49 ms on a quiet machine and 64·79 ms with a dozen agents running, on a build that
|
||
// an A/B against the pre-session one showed to be FASTER (39·41·43·44·47 against 42·43·44·46·48).
|
||
// The threshold below has not moved; what moved is the noise of the reading.
|
||
/** @type {number[]} */
|
||
const echoes = [];
|
||
let settled = 0;
|
||
for (let round = 0; round < 5; round += 1) {
|
||
await search.fill('');
|
||
await page.waitForTimeout(300);
|
||
await search.click();
|
||
const typed = Date.now();
|
||
await page.keyboard.type(query);
|
||
await page.waitForFunction(
|
||
(want) =>
|
||
/** @type {HTMLInputElement | null} */ (document.querySelector('input[type="search"]'))
|
||
?.value === want,
|
||
query,
|
||
{ timeout: 5000 },
|
||
);
|
||
echoes.push(Math.round((Date.now() - typed) / query.length));
|
||
await page.waitForFunction(
|
||
({ was, selector }) => document.querySelectorAll(selector).length < was,
|
||
{ was: before, selector: rowsSelector },
|
||
{ timeout: 5000 },
|
||
);
|
||
settled = Date.now() - typed;
|
||
}
|
||
echoes.sort((a, b) => a - b);
|
||
const echo = echoes[Math.floor(echoes.length / 2)] ?? 0;
|
||
console.log(
|
||
` search over 1200 terms: the field answers in ${echo}ms (of ${echoes.join('·')}), ` +
|
||
`the table settles in ${settled}ms`,
|
||
);
|
||
// The threshold comes from a measurement, not out of the head: before the fix it was ~150 ms per
|
||
// character (the whole shell repainted), after it 40–45. 60 separates one from the other.
|
||
check(echo <= 60, `the input field answers in ${echo}ms per character (threshold 60)`);
|
||
check(settled <= 700, `the table settles in ${settled}ms (threshold 700)`);
|
||
|
||
// Dragging the panel with the bank open — what the owner complained about in the words
|
||
// "fucking lags".
|
||
await search.fill('');
|
||
const drag = await dragFrames(page);
|
||
console.log(
|
||
` dragging the panel with the bank: median ${drag.median}ms, p90 ${drag.p90}ms, worst ${drag.worst}ms, ` +
|
||
`frames longer than 50ms — ${drag.janky} of ${drag.frames}`,
|
||
);
|
||
check(
|
||
drag.moved > 100,
|
||
`the panel really travelled during the drag (by ${drag.moved}px at the peak)`,
|
||
);
|
||
check(drag.p90 <= 34, `the p90 frame during the drag is ${drag.p90}ms (threshold 34)`);
|
||
check(drag.janky <= 3, `long frames over the drag ${drag.janky} (threshold 3)`);
|
||
},
|
||
|
||
// Remarks 11, 12, 4: three modal windows instead of stub tabs and a second search button.
|
||
/** @param {Page} page @param {Shot} shot */
|
||
async overlays(page, shot) {
|
||
await open(page, '/showcase');
|
||
// ⚠ Everything is looked for INSIDE the window. Because of keep-alive the unselected panels stay
|
||
// in the DOM (that is the whole point of Ф-19), so a global locator by role finds the hidden row
|
||
// of the neighbouring tab and waits for it to be visible until the timeout.
|
||
const modal = page.getByRole('dialog');
|
||
|
||
await page.getByRole('button', { name: say('shell.settingsAction') }).click();
|
||
await shot('modal-settings');
|
||
check(await modal.isVisible(), 'the settings opened as a modal window');
|
||
await audit(page, 'the settings modal window');
|
||
await page.keyboard.press('Escape');
|
||
|
||
await page.getByRole('button', { name: say('library.addBook') }).click();
|
||
await modal.locator('input[type="file"]').setInputFiles({
|
||
name: 'gu-zhen-ren.txt',
|
||
mimeType: 'text/plain',
|
||
buffer: Buffer.from('sample'),
|
||
});
|
||
await shot('modal-add-book');
|
||
check(
|
||
await modal.getByText(say('addBook.titleHintAuto'), { exact: false }).isVisible(),
|
||
'an empty title field honestly says that the title will be given by parsing the file',
|
||
);
|
||
await modal.getByRole('textbox').last().fill(book.title);
|
||
check(
|
||
await modal.getByText(say('addBook.titleHintManual'), { exact: false }).isVisible(),
|
||
'a title entered by hand differs from the automatic parse',
|
||
);
|
||
await page.keyboard.press('Escape');
|
||
|
||
await page.getByRole('button', { name: say('shell.gotoAction') }).click();
|
||
await page.keyboard.type(heading('ch_7'));
|
||
await shot('modal-goto');
|
||
const results = modal.locator('[role="option"]');
|
||
check(
|
||
(await results.count()) === 1 &&
|
||
(await results.first().innerText()).includes(heading('ch_7')),
|
||
'the jump palette finds exactly the chapter that was named',
|
||
);
|
||
await results.first().click();
|
||
// ⚠ Counting preview tabs here is pointless: one of them is open in the showcase always, and the
|
||
// check would pass even if picking in the palette did NOTHING. We ask for the NAME.
|
||
check(
|
||
(await page.locator('[role="tab"][data-preview="true"]').innerText()).includes(
|
||
heading('ch_7'),
|
||
),
|
||
'the chapter picked in the palette is the one that opened — as a preview tab',
|
||
);
|
||
|
||
// Escape closes the palette on the FIRST press: the search primitive clears its line on the
|
||
// first press.
|
||
await page.getByRole('button', { name: say('shell.gotoAction') }).click();
|
||
await page.keyboard.type(heading('ch_7'));
|
||
await page.keyboard.press('Escape');
|
||
check(!(await modal.isVisible()), 'Escape closes the palette on the first press');
|
||
|
||
// ⚠ The price of that fix: intercepting Escape stops the field from clearing itself, and the
|
||
// palette opened with the OLD query — the next letter was appended to it (found by an
|
||
// adversarial review).
|
||
await page.getByRole('button', { name: say('shell.gotoAction') }).click();
|
||
check(
|
||
(await modal.getByRole('searchbox').inputValue()) === '',
|
||
'a palette opened anew starts from a clean line',
|
||
);
|
||
await page.keyboard.press('Escape');
|
||
|
||
// A request from the palette outweighs the filter by kind: it otherwise leads into the bank and
|
||
// shows emptiness there.
|
||
const panel = page.locator('aside');
|
||
await panel.getByRole('tab', { name: say('bank.tab') }).click();
|
||
await panel.getByRole('radio', { name: new RegExp(`^${say('kind.place')}`) }).click();
|
||
await page.getByRole('button', { name: say('shell.gotoAction') }).click();
|
||
await page.keyboard.type(dstOf(terms, 't_1'));
|
||
await modal.locator('[role="option"]').first().click();
|
||
await page.waitForTimeout(700);
|
||
const table = panel.getByRole('grid', { name: say('bank.tableLabel') });
|
||
check(
|
||
(await table
|
||
.getByRole('row')
|
||
.filter({ hasText: dstOf(terms, 't_1') })
|
||
.count()) === 1,
|
||
'the term picked in the palette is visible in the bank, though a filter of another kind was on',
|
||
);
|
||
|
||
// The same term, asked for a SECOND time, is a request as well.
|
||
await panel.getByRole('searchbox', { name: say('bank.search') }).fill('zzz');
|
||
await page.getByRole('button', { name: say('shell.gotoAction') }).click();
|
||
await page.keyboard.type(dstOf(terms, 't_1'));
|
||
await modal.locator('[role="option"]').first().click();
|
||
await page.waitForTimeout(700);
|
||
check(
|
||
(await table
|
||
.getByRole('row')
|
||
.filter({ hasText: dstOf(terms, 't_1') })
|
||
.count()) === 1,
|
||
'a repeated request for the same term works the same way',
|
||
);
|
||
},
|
||
};
|
||
|
||
/**
|
||
* How the columns of a table lie in their panel: the sum of their widths, the width of the content
|
||
* and of the window, and what is clipped. One measurement for three scenarios — the bank, the hidden
|
||
* tab, the zoom.
|
||
* @param {Locator} table
|
||
*/
|
||
function columnSpread(table) {
|
||
return 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);
|
||
/** @type {string[]} */
|
||
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,
|
||
widths: heads.map((th) => Math.round(th.getBoundingClientRect().width)),
|
||
cut,
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Pairs of «content ↔ ground of the selected row» that did not reach the WCAG floor. Computed INSIDE
|
||
* the page over the computed colours and not over the tokens: what has to be guarded is what is seen
|
||
* on the screen.
|
||
*
|
||
* The floor is taken by role: text 4.5:1, an icon and a surface (the track of the indicator) 3:1.
|
||
* ⚠ One pair is deliberately not in here — «fill ↔ track» inside the indicator itself: above 2.07:1
|
||
* it cannot be raised on a blue ground with the owner's palette, and that is named in
|
||
* Library.module.css.
|
||
* @param {Page} page
|
||
*/
|
||
function contrastInSelectedRow(page) {
|
||
return page.evaluate(() => {
|
||
const luminance = (/** @type {string} */ color) => {
|
||
const [r = 0, g = 0, b = 0] = (color.match(/[\d.]+/g) ?? []).map(Number);
|
||
const lin = (/** @type {number} */ c) =>
|
||
c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4;
|
||
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||
};
|
||
const ratio = (/** @type {string} */ a, /** @type {string} */ b) => {
|
||
const [high = 0, low = 0] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||
return Math.round((100 * (high + 0.05)) / (low + 0.05)) / 100;
|
||
};
|
||
|
||
const row = document.querySelector('nav [role="row"][data-selected]');
|
||
if (!row) throw new Error('there is no selected row in the tree — nothing to guard');
|
||
const ground = getComputedStyle(row).backgroundColor;
|
||
const parts = [];
|
||
for (const node of row.querySelectorAll('*')) {
|
||
const style = getComputedStyle(node);
|
||
const label = (node.textContent ?? '').trim().slice(0, 24);
|
||
// The label for the reader is cut out of the frame (clip-path) — no contrast is asked of it.
|
||
const printed = [...node.childNodes].some(
|
||
(child) => child.nodeType === 3 && (child.textContent ?? '').trim() !== '',
|
||
);
|
||
if (printed && style.clipPath === 'none') {
|
||
parts.push({ what: `text «${label}»`, floor: 4.5, ratio: ratio(style.color, ground) });
|
||
}
|
||
if (node.tagName === 'svg') {
|
||
parts.push({ what: 'icon', floor: 3, ratio: ratio(style.color, ground) });
|
||
}
|
||
// By the ALPHA, and not by a comparison with the string of a transparent colour: the browser
|
||
// prints it in different ways, and a literal colour in the code is forbidden by the gate — and
|
||
// rightly so, the comparison has to be against the property.
|
||
const opaque = Number((style.backgroundColor.match(/[\d.]+/g) ?? [])[3] ?? 1) > 0;
|
||
if (opaque && style.clipPath === 'none') {
|
||
parts.push({
|
||
what: `surface «${label}»`,
|
||
floor: 3,
|
||
ratio: ratio(style.backgroundColor, ground),
|
||
});
|
||
}
|
||
}
|
||
return parts.filter((part) => part.ratio < part.floor);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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('the right separator was not found');
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* A drag of the right separator there and back with the frames measured. What is measured is EXACTLY
|
||
* what a person complains about: not an abstract render, but dragging the window with the table
|
||
* open.
|
||
* @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('the right separator was not found');
|
||
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 });
|
||
// The width is taken DURING the drag: by the end the mouse comes back where it was, and a
|
||
// "before/after" comparison would show zero — that is, the measurement would pass on a panel
|
||
// stuck dead as well.
|
||
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),
|
||
// A person feels the LONG frames, not the median: it held at 17 ms even where the drag was
|
||
// torn to shreds. We count them by the piece.
|
||
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);
|
||
await settle(page);
|
||
}
|
||
|
||
/**
|
||
* Waits for the animations to play out: the frame and the measurement are bound to land on a settled
|
||
* state and not in the middle of a transition. Infinite animations we have none — should any appear,
|
||
* the wait will honestly run into its timeout instead of taking a blinking frame for a settled one.
|
||
* @param {Page} page
|
||
*/
|
||
const settle = (page) =>
|
||
page.waitForFunction(
|
||
() => document.getAnimations().every((animation) => animation.playState !== 'running'),
|
||
null,
|
||
{ timeout: 5000 },
|
||
);
|
||
|
||
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(
|
||
`No such scenario: ${unknown.join(', ')}. Known ones: ${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 did not give back a local address');
|
||
|
||
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('\nevery scenario passed');
|