diff --git a/perf/README.md b/perf/README.md index 184974d..102381a 100644 --- a/perf/README.md +++ b/perf/README.md @@ -70,6 +70,10 @@ cd .. && .venv/bin/python -m perfkit.aggregate results/s1 - Метрики: `main_thread_ms`, `main_thread_ms_per_step`, probe (long tasks, кадры, `frame_excess_ms`), `axis` (0 = y, 1 = x), `rows_visible_before/after`, `json_bytes_sheet` (размер распакованного ответа загрузки данных из сетевого лога), `steps`. Записи с `skip_reason` (`no_overflow`, `rows<20`, `filter_no_effect`, `scroll_incomplete`...) всегда `incomplete:true`; в конце прогона они выводятся списком. Метрика `traced=1` - замер с включённым трейсом (повтор 0), aggregate исключает его из статистики. Поэтому trace снимается только если повторов больше одного (`lib/trace.ts` `shouldTrace`): у admin-контроля, control4x и при `PERF_REPEATS=1` trace нет, а единственная запись идёт в статистику. Trace первого повтора - в `perf/results//artifacts/` (30-70 МБ каждый, в git не попадают). - Сайт свода не читает `#hash` из URL: вкладка (`FORM_1`, `GO`...) выбирается кликом по `role=tab`. +### Перетаскивание ширины закреплённой колонки + +`perf/e2e/scenarios/pinned-resize.spec.ts` (AURORA-1839): роль `rf` закрепляет первую колонку данных листа и тянет её границу 50 x (+4 px), затем 50 x (-4 px) с ожиданием кадра на шаге; после отпускания `lib/pinnedGeometry.ts` (`findPinnedGap`) проверяет примыкание `td[data-pinned]`. Запись: `scenario: pinned_resize`, `target: <лист>@pinned`, метрики `main_thread_ms_per_step` (шаг = движение мыши), `frame_p95_ms`, `heavy_frame_n`; при наложении колонок `incomplete` и `skip_reason: pinned_overlap` (`pinned_gap_index`). Для сборки базы без `data-col-resize-id` ресайзер ищется по `cursor: col-resize`. Команда: `PERF_TARGETS='FORM_2/AHR/*' PERF_TRACE=0 PERF_REPEATS=3 npx playwright test scenarios/pinned-resize.spec.ts`. Самопроверка геометрии: `node --test perf/e2e/tests/pinned-geometry.test.ts`. + ### Скролл при соавторе `perf/e2e/scenarios/scroll-coauthor.spec.ts`, бот `perf/perfkit/coauthor.py`. Автор `perf_rf` (роль rf) скроллит лист по оси y, пока бот `perf_rf_coauthor` правит числовые ячейки того же листа по WebSocket с частотой `rate` правок/с. Цели — самый большой лист каждой формы среди `owner=rf` (FORM_2/AHR 1154 строки, FORM_4/STRUCTURE 775; FORM_1 у rf нет). Бот стартует после загрузки листа и ждёт эха первой правки (READY), работает до SIGTERM после скролла. Только масштаб 1 (так в плане). diff --git a/perf/e2e/lib/pinnedGeometry.ts b/perf/e2e/lib/pinnedGeometry.ts new file mode 100644 index 0000000..b0831d8 --- /dev/null +++ b/perf/e2e/lib/pinnedGeometry.ts @@ -0,0 +1,9 @@ +/** Проверка раскладки закреплённых колонок (spec sheet-table): ячейки td[data-pinned] одной строки по порядку примыкают. */ +export interface PinnedCellRect { left: number; width: number } + +export function findPinnedGap(cells: PinnedCellRect[], tolerancePx = 1): number { + for (let i = 0; i + 1 < cells.length; i++) { + if (Math.abs(cells[i + 1].left - (cells[i].left + cells[i].width)) > tolerancePx) return i; + } + return -1; +} diff --git a/perf/e2e/scenarios/pinned-resize.spec.ts b/perf/e2e/scenarios/pinned-resize.spec.ts new file mode 100644 index 0000000..64a00cd --- /dev/null +++ b/perf/e2e/scenarios/pinned-resize.spec.ts @@ -0,0 +1,183 @@ +/** + * Перетаскивание ширины закреплённой колонки (AURORA-1839, spec sheet-table). + * + * Закрепляет первую колонку данных листа (роль rf), тянет её границу 50 x (+4 px) и 50 x (-4 px) с ожиданием кадра + * после каждого шага, после отпускания проверяет, что td[data-pinned="true"] одной строки примыкают друг к другу. + * + * Параметры (env): + * PERF_TARGETS glob(ы) через запятую по имени листа `FORM/SHEET/direction` (по умолчанию 'FORM_2/AHR/*'); + * сопоставляется с именем и с `<имя>@pinned` + * PERF_REPEATS число повторов (по умолчанию 3) + * PERF_CPU_RATES '1' по умолчанию + * PERF_LOAD_TIMEOUT_MS ожидание данных после навигации (по умолчанию 900000) + * PERF_TEST_TIMEOUT_MS таймаут теста (по умолчанию 45 мин) + * PERF_RUN_ID, PERF_SCALE - как у scroll.spec.ts + * + * Метрика: main_thread_ms_per_step (шаг = движение мыши), frame_p95_ms, heavy_frame_n. + * skip_reason: resizer_not_found, pinned_overlap (+ pinned_gap_index), rows<20. + */ +import { test, type Page } from '@playwright/test'; +import { loadManifest, scale } from '../lib/manifest'; +import { loginAs, WEB } from '../lib/auth'; +import { PROBE_SCRIPT, startProbe, perStep } from '../lib/probe'; +import { throttle } from '../lib/cdp'; +import { attachNetlog } from '../lib/netlog'; +import { append } from '../lib/results'; +import { findPinnedGap, type PinnedCellRect } from '../lib/pinnedGeometry'; + +const CONTAINER = '.MuiTableContainer-root'; +const MIN_ROWS = 20; +const LOAD_TIMEOUT = Number(process.env.PERF_LOAD_TIMEOUT_MS ?? 900_000); +const STEP_PX = 4; +const HALF_STEPS = 50; + +const envList = (n: string, d: string[]) => (process.env[n] ? process.env[n]!.split(',').map((s) => s.trim()).filter(Boolean) : d); +const CPU_RATES = envList('PERF_CPU_RATES', ['1']).map(Number); +const REPEATS = process.env.PERF_REPEATS ? Number(process.env.PERF_REPEATS) : 3; +const globRe = (g: string) => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$'); +const TARGET_RES = envList('PERF_TARGETS', ['FORM_2/AHR/*']).map(globRe); +const wanted = (name: string) => TARGET_RES.some((r) => r.test(name) || r.test(`${name}@pinned`)); + +const sheetName = (s: { form_type: string; sheet: string; direction: string | null }) => `${s.form_type}/${s.sheet}/${s.direction ?? 'null'}`; +const nextFrame = (page: Page) => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => r()))); + +const skipLog: string[] = []; + +async function measure(browser: import('@playwright/test').Browser, s: ReturnType['sheets'][number], name: string, cpu: number, repeat: number) { + const m = loadManifest(); + const user = m.users['rf']; + const target = `${name}@pinned`; + const rec = { scenario: 'pinned_resize', target, role: 'rf', scale: scale(), cpu_rate: cpu, repeat }; + const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } }); + const nl = attachNetlog(ctx, { scenario: rec.scenario, target, role: 'rf' }); + const skip = (reason: string, metrics: Record = {}) => { + skipLog.push(`${target} rf cpu${cpu} #${repeat}: ${reason}`); + append({ ...rec, metrics, artifacts: [], incomplete: true, skip_reason: reason }); + }; + try { + await loginAs(ctx, user); + // раскладка по умолчанию: ключи может оставить прошлый прогон; direction в ключе бывает строкой null/undefined - чистим по префиксу + await ctx.addInitScript( + (prefix: string) => { + try { + for (const k of Object.keys(localStorage)) { + if (k.startsWith(prefix) && /_(columnPinning|columnSizing|columnVisibility)$/.test(k)) localStorage.removeItem(k); + } + } catch { /* ignore */ } + }, + `${s.form_id}_${s.form_type}_${s.sheet}_`, + ); + await ctx.addInitScript(PROBE_SCRIPT); + const page = await ctx.newPage(); + await page.goto(WEB + s.url); + try { + await page.waitForFunction( + ([sel, min]) => document.querySelectorAll(`${sel} tbody tr`).length >= (min as number), + [CONTAINER, MIN_ROWS] as const, + { timeout: LOAD_TIMEOUT }, + ); + } catch { + const have = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER).catch(() => -1); + skip(`rows<${MIN_ROWS}: timeout, rows=${have} (url ${page.url()})`, { rows_visible_before: have }); + return; + } + await page.waitForTimeout(500); + await throttle(page, cpu); + + // колонка: первая data.* в DOM-порядке, иначе первая кроме служебных + const colId = await page.evaluate(() => { + const ids = Array.from(document.querySelectorAll('[data-col-select-id]')).map((e) => e.getAttribute('data-col-select-id') as string); + return ids.find((id) => id.startsWith('data.')) ?? ids.find((id) => id !== 'mrt-row-expand' && id !== 'sort_order') ?? null; + }); + if (!colId) { skip('column_not_found'); return; } + await page.locator(`[data-col-select-id="${colId}"]`).first().click(); + const pinBtn = page.locator('[data-pin-panel] button[aria-label="Закрепить область"]').first(); + await pinBtn.click(); + await page.waitForSelector('[data-pin-panel] button[aria-label="Закрепить область"][data-active="true"]'); + + // ресайзер: атрибут из ColumnResizer, иначе запасной поиск по cursor: col-resize (сборка базы AURORA-1837) + let box: { x: number; y: number; width: number; height: number } | null = null; + const byAttr = page.locator(`[data-col-resize-id="${colId}"]`).first(); + if (await byAttr.count()) box = await byAttr.boundingBox(); + else { + box = await page.evaluate((id) => { + const th = document.querySelector(`[data-col-select-id="${id}"]`) as HTMLElement | null; + if (!th) return null; + const thRect = th.getBoundingClientRect(); + const letters = Array.from(document.querySelectorAll('[data-col-select-id]')).map((e) => e.getBoundingClientRect().top); + const lettersTop = Math.min(...letters); + const cands = Array.from(document.querySelectorAll('div')).filter((d) => { + if ((d as HTMLElement).style.cursor !== 'col-resize') return false; + const r = d.getBoundingClientRect(); + return Math.abs(r.right - thRect.right) <= 2 && r.top > lettersTop; + }); + if (!cands.length) return null; + const best = cands.reduce((a, b) => (b.getBoundingClientRect().top > a.getBoundingClientRect().top ? b : a)); + const r = best.getBoundingClientRect(); + return { x: r.x, y: r.y, width: r.width, height: r.height }; + }, colId); + } + if (!box) { skip('resizer_not_found'); return; } + + const x0 = box.x + box.width / 2; + const y = box.y + box.height / 2; + await page.mouse.move(x0, y); + await page.mouse.down(); + const pr = await startProbe(page); + let x = x0; + const steps = HALF_STEPS * 2; + for (let i = 0; i < steps; i++) { + x += i < HALF_STEPS ? STEP_PX : -STEP_PX; + await page.mouse.move(x, y); + await nextFrame(page); + } + await page.mouse.up(); + await nextFrame(page); + await nextFrame(page); + const probe = await pr.stop(); + + const rects: PinnedCellRect[] = await page.evaluate(() => { + for (const tr of Array.from(document.querySelectorAll('tbody tr'))) { + const tds = Array.from(tr.querySelectorAll('td[data-pinned="true"]')); + if (tds.length >= 2) return tds.map((td) => { const r = td.getBoundingClientRect(); return { left: r.left, width: r.width }; }); + } + return []; + }); + const gap = findPinnedGap(rects); + const overlap = gap >= 0; + if (overlap) skipLog.push(`${target} rf cpu${cpu} #${repeat}: pinned_overlap at ${gap}`); + append({ + ...rec, + metrics: { ...probe, ...perStep(probe, steps), steps, ...(overlap ? { pinned_gap_index: gap } : {}) }, + artifacts: [], + incomplete: overlap, + ...(overlap ? { skip_reason: 'pinned_overlap' } : {}), + }); + } finally { + await nl.flush(); + await ctx.close(); + } +} + +const testTimeout = () => Number(process.env.PERF_TEST_TIMEOUT_MS ?? 45 * 60_000); + +test.describe('pinned_resize', () => { + test.afterAll(() => { + if (skipLog.length) console.log(`\n=== записи с skip_reason / incomplete (${skipLog.length}) ===\n` + skipLog.join('\n')); + }); + const m = loadManifest(); + if (!m.users['rf']) return; + for (const s of m.sheets) { + if (s.owner !== 'rf') continue; + const name = sheetName(s); + if (!wanted(name)) continue; + for (const cpu of CPU_RATES) { + for (let repeat = 0; repeat < REPEATS; repeat++) { + test(`${name} rf pinned cpu${cpu} #${repeat}`, async ({ browser }) => { + test.setTimeout(testTimeout()); + await measure(browser, s, name, cpu, repeat); + }); + } + } + } +}); diff --git a/perf/e2e/tests/pinned-geometry.test.ts b/perf/e2e/tests/pinned-geometry.test.ts new file mode 100644 index 0000000..ba95603 --- /dev/null +++ b/perf/e2e/tests/pinned-geometry.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { findPinnedGap } from '../lib/pinnedGeometry.ts'; + +test('примыкающие закреплённые колонки: -1', () => { + assert.equal(findPinnedGap([{ left: 0, width: 40 }, { left: 40, width: 120 }, { left: 160, width: 300 }]), -1); +}); + +test('наложение после смены ширины: индекс левой колонки пары', () => { + assert.equal(findPinnedGap([{ left: 0, width: 40 }, { left: 40, width: 200 }, { left: 160, width: 300 }]), 1); +}); + +test('зазор: индекс левой колонки пары', () => { + assert.equal(findPinnedGap([{ left: 0, width: 40 }, { left: 60, width: 120 }]), 0); +}); + +test('дробные пиксели в пределах допуска 1 px: -1', () => { + assert.equal(findPinnedGap([{ left: 0, width: 40.4 }, { left: 41.2, width: 10 }]), -1); +}); + +test('меньше двух колонок: -1', () => { + assert.equal(findPinnedGap([]), -1); + assert.equal(findPinnedGap([{ left: 0, width: 40 }]), -1); +});