fix(ui): пункты меню не прячутся под футером; сквозные сценарии панели
Some checks failed
ci / test (3.10) (push) Has been cancelled
ci / test (3.11) (push) Has been cancelled
ci / test (3.12) (push) Has been cancelled

web/index.html:
- мобильное меню после раскрытия не держит анимационную высоту Bootstrap:
  нижние пункты (Обзор/История/API-ключи) уезжали под .sidebar-footer и не нажимались
- на десктопе рельс #nav скроллится сам, футер не сжимается; активный пункт
  доскролливается в область видимости
- failure() принимает заголовок: ошибка валидации источника больше не подписана
  «Не удалось загрузить данные»

tools/reachability.py: общая проверка «элемент виден, но перекрыт другим» —
hit-test по центру с scrollIntoView, пропуск скрытых узлов и закрытых <details>.

tools/panel_scenarios.py: 11 сквозных сценариев реального пользователя —
deep-link фильтр, экспорт CSV/JSON по байтам, читалка и ссылка на оригинал,
дайджест, поиск источников, Telegram-сессия, форма источника (отказ без записи
в реестр), клавиатурный flow (/ ? Esc 1-9) со сверкой строк ленты с API,
мобильное меню, адаптивность 320-1920 без горизонтального переполнения, зум.

tools/ui_smoke.py: reachability-проверки десктопного рельса, контролов и мобильного меню.
tests: +3 регрессии на CSS/заголовки ошибок; pytest 93 passed, smoke exit 0,
сценарии exit 0, page_errors пуст.
This commit is contained in:
Vlad Gothbreach
2026-10-27 05:45:14 +03:00
parent 0831984918
commit 92bf9eaa12
6 changed files with 699 additions and 6 deletions

56
tools/reachability.py Normal file
View File

@@ -0,0 +1,56 @@
"""Pointer-reachability check for the OMNI panel.
A control whose centre is covered by another element cannot be clicked by a human, and
Playwright stalls on it instead of failing fast. Regression guard for the mobile sidebar
bug where the collapsed menu kept a fixed height: nav links slid under `.sidebar-footer`,
so the last items ("Обзор", "Запуски", "Ключи") were visible in DevTools but untappable.
Used by tools/ui_smoke.py and tools/panel_scenarios.py.
"""
from __future__ import annotations
from typing import Any, Optional
from playwright.async_api import Page
JS = """(args) => {
const describe = (el) => {
if (!el) return 'none';
const chain = [];
let cur = el;
for (let i = 0; i < 3 && cur; i++) {
const cls = typeof cur.className === 'string' && cur.className
? '.' + cur.className.trim().split(/\\s+/).slice(0, 2).join('.') : '';
chain.push(cur.tagName.toLowerCase() + (cur.id ? '#' + cur.id : '') + cls);
cur = cur.parentElement;
}
return chain.join(' < ');
};
const covered = [];
const els = [...document.querySelectorAll(args.selector)];
els.forEach((el, i) => {
const key = (args.keyAttr && el.dataset[args.keyAttr]) || el.id
|| (el.textContent || '').trim().slice(0, 28) || (args.selector + '[' + i + ']');
const details = el.closest('details');
if (details && !details.open) return;
const style = getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return;
if (el.closest('#sug:not(.show), #drawer:not(.show), #modal:not(.show)')) return;
el.scrollIntoView({block: 'center'});
const r = el.getBoundingClientRect();
if (r.width < 2 || r.height < 2) return;
const x = r.left + r.width / 2, y = r.top + r.height / 2;
if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) return;
const top = document.elementFromPoint(x, y);
if (top && (top === el || el.contains(top))) return;
covered.push({key, covered_by: describe(top), at: [Math.round(x), Math.round(y)],
box: {x: Math.round(r.left), y: Math.round(r.top),
w: Math.round(r.width), h: Math.round(r.height)}});
});
return covered.slice(0, 12);
}"""
async def find_covered(page: Page, selector: str, key_attr: Optional[str] = None) -> list[dict[str, Any]]:
"""Return controls matched by `selector` whose centre is painted over by another element."""
return await page.evaluate(JS, {"selector": selector, "keyAttr": key_attr})