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 пуст.
57 lines
2.6 KiB
Python
57 lines
2.6 KiB
Python
"""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})
|