fix(ui): пункты меню не прячутся под футером; сквозные сценарии панели
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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,3 +6,5 @@ __pycache__/
|
|||||||
.bot_token
|
.bot_token
|
||||||
*.session*
|
*.session*
|
||||||
.local/
|
.local/
|
||||||
|
# scratch/debug artifacts in the repo root (probes, logs) - never commit
|
||||||
|
/_*
|
||||||
|
|||||||
@@ -121,3 +121,40 @@ def test_panel_typography_does_not_clip_cyrillic():
|
|||||||
assert rule, selector
|
assert rule, selector
|
||||||
line_height = re.search(r"line-height: ([\d.]+)", rule.group(0))
|
line_height = re.search(r"line-height: ([\d.]+)", rule.group(0))
|
||||||
assert line_height and float(line_height.group(1)) >= 1.4, rule.group(0)
|
assert line_height and float(line_height.group(1)) >= 1.4, rule.group(0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sidebar_menu_never_clamps_under_its_footer():
|
||||||
|
"""Mobile kept Bootstrap's animated height, so the lower nav links slid under .sidebar-footer:
|
||||||
|
they had boxes in DevTools but no tap reached them. Desktop rail scrolls instead of overlapping."""
|
||||||
|
html = (WEB_ROOT / "index.html").read_text(encoding="utf-8")
|
||||||
|
mobile = re.search(r"@media \(max-width: 991\.98px\) \{(.*?)\n\}", html, re.S)
|
||||||
|
assert mobile, "mobile media query missing"
|
||||||
|
assert "#sidebar-menu.show:not(.collapsing) { height: auto !important" in mobile.group(1)
|
||||||
|
assert "#sidebar-menu > #nav { flex: 0 0 auto; overflow: visible; }" in mobile.group(1)
|
||||||
|
assert ".sidebar-footer { flex: 0 0 auto;" in mobile.group(1)
|
||||||
|
desktop = re.search(r"@media \(min-width: 992px\) \{(.*?)\n\}", html, re.S)
|
||||||
|
assert desktop, "desktop media query missing"
|
||||||
|
assert "#sidebar-menu > #nav { flex: 1 1 auto; min-height: 0; overflow-y: auto" in desktop.group(1)
|
||||||
|
assert re.search(r"(?m)^\.sidebar-footer \{ flex: 0 0 auto;", html), "footer must not shrink"
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_errors_are_labelled_for_their_situation():
|
||||||
|
"""A rejected source used to render «Не удалось загрузить данные» - a load failure title
|
||||||
|
for a plain validation mistake. failure() now takes a title, and the form passes its own."""
|
||||||
|
html = (WEB_ROOT / "index.html").read_text(encoding="utf-8")
|
||||||
|
assert 'function failure(error,retry,title="Не удалось загрузить данные")' in html
|
||||||
|
assert '<h3 class="alert-title">${esc(title)}</h3>' in html
|
||||||
|
call = (
|
||||||
|
'failure(new Error("Нужен адрес с протоколом https:// или http://")'
|
||||||
|
',null,"Проверьте адрес источника")'
|
||||||
|
)
|
||||||
|
assert call in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_nav_scrolls_into_view_in_the_desktop_rail():
|
||||||
|
"""The desktop rail scrolls, so a deep-linked section below the fold must reveal itself."""
|
||||||
|
html = (WEB_ROOT / "index.html").read_text(encoding="utf-8")
|
||||||
|
assert (
|
||||||
|
'if(rail.scrollHeight>rail.clientHeight)'
|
||||||
|
'rail.querySelector(".nav-link.active")?.scrollIntoView({block:"nearest"})'
|
||||||
|
) in html
|
||||||
|
|||||||
574
tools/panel_scenarios.py
Normal file
574
tools/panel_scenarios.py
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
"""End-to-end user scenarios for the OMNI panel (real clicks, real bytes, real geometry).
|
||||||
|
|
||||||
|
Complements tools/ui_smoke.py: instead of asserting that every route paints, it walks the
|
||||||
|
flows an operator actually performs - deep-link filters, exports, digest, reader download,
|
||||||
|
Telegram status, source search, mobile offcanvas - and fails loudly on any of them.
|
||||||
|
|
||||||
|
Every scenario runs under a hard wall-clock guard, so a stuck Playwright call becomes a
|
||||||
|
named failure instead of a silent hang.
|
||||||
|
|
||||||
|
Run: python tools/panel_scenarios.py [--url http://127.0.0.1:8078] [--shots DIR]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from playwright.async_api import Page, async_playwright
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
||||||
|
from reachability import find_covered # noqa: E402
|
||||||
|
|
||||||
|
BASE = "http://127.0.0.1:8078"
|
||||||
|
T = 12000 # per-action guard; Playwright defaults proved unreliable on Windows
|
||||||
|
|
||||||
|
COLLISION_JS = """() => {
|
||||||
|
const painted = (el, x, y) => { const top = document.elementFromPoint(x, y); return !!top && (top === el || el.contains(top) || top.contains(el)); };
|
||||||
|
const boxes = [];
|
||||||
|
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
||||||
|
let node;
|
||||||
|
while ((node = walker.nextNode())) {
|
||||||
|
if (!node.textContent.trim()) continue;
|
||||||
|
const el = node.parentElement;
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) continue;
|
||||||
|
if (el.closest('#sug:not(.show), #drawer:not(.show), #modal:not(.show), .visually-hidden-focusable:not(:focus)')) continue;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(node);
|
||||||
|
for (const rect of range.getClientRects()) {
|
||||||
|
if (rect.width < 4 || rect.height < 4) continue;
|
||||||
|
boxes.push({el, rect, text: node.textContent.trim().slice(0, 28)});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const found = [];
|
||||||
|
for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
|
||||||
|
const a = boxes[i], b = boxes[j];
|
||||||
|
if (a.el === b.el || a.el.contains(b.el) || b.el.contains(a.el)) continue;
|
||||||
|
const x = Math.min(a.rect.right, b.rect.right) - Math.max(a.rect.left, b.rect.left);
|
||||||
|
const y = Math.min(a.rect.bottom, b.rect.bottom) - Math.max(a.rect.top, b.rect.top);
|
||||||
|
if (x <= 3 || y <= 3) continue;
|
||||||
|
const cx = (Math.max(a.rect.left, b.rect.left) + Math.min(a.rect.right, b.rect.right)) / 2;
|
||||||
|
const cy = (Math.max(a.rect.top, b.rect.top) + Math.min(a.rect.bottom, b.rect.bottom)) / 2;
|
||||||
|
if (painted(a.el, cx, cy) && painted(b.el, cx, cy)) found.push(a.text + ' <> ' + b.text);
|
||||||
|
}
|
||||||
|
return {collisions: found.slice(0, 5), scrollW: document.documentElement.scrollWidth, innerW: innerWidth};
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
class Runner:
|
||||||
|
"""Scenario context: one page, shared report, guarded steps."""
|
||||||
|
|
||||||
|
def __init__(self, page: Page, url: str, shots: pathlib.Path):
|
||||||
|
self.page = page
|
||||||
|
self.url = url.rstrip("/")
|
||||||
|
self.shots = shots
|
||||||
|
self.out: dict[str, Any] = {"url": self.url, "scenarios": {}}
|
||||||
|
self.errors: list[str] = []
|
||||||
|
|
||||||
|
async def settle(self):
|
||||||
|
try:
|
||||||
|
await self.page.wait_for_load_state("networkidle", timeout=5000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def goto_hash(self, hash_part: str, selector: str | None = None):
|
||||||
|
"""Routing lives in location.hash; page.goto() would hang on a same-document change."""
|
||||||
|
await self.page.evaluate("h => { location.hash = h; }", hash_part)
|
||||||
|
if selector:
|
||||||
|
await self.page.wait_for_selector(selector, timeout=25000)
|
||||||
|
await self.settle()
|
||||||
|
|
||||||
|
async def no_collision(self, label: str):
|
||||||
|
res = await self.page.evaluate(COLLISION_JS)
|
||||||
|
assert not res["collisions"], f"{label}: text on text {res['collisions']}"
|
||||||
|
assert res["scrollW"] <= res["innerW"] + 1, (
|
||||||
|
f"{label}: horizontal overflow {res['scrollW']} > {res['innerW']}"
|
||||||
|
)
|
||||||
|
self.out["scenarios"].setdefault("no_collision", []).append(label)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- scenarios
|
||||||
|
async def deep_link(self):
|
||||||
|
page = self.page
|
||||||
|
await page.goto(self.url + "/#/feed", wait_until="domcontentloaded", timeout=30000)
|
||||||
|
await page.wait_for_selector("#list .item", timeout=25000)
|
||||||
|
await self.settle()
|
||||||
|
rows_all = await page.locator("#list .item").count()
|
||||||
|
source = (
|
||||||
|
await page.locator("#list .item .feed-content .small > span:first-child")
|
||||||
|
.first.inner_text(timeout=T)
|
||||||
|
).strip()
|
||||||
|
assert source, "feed row has no source label"
|
||||||
|
await self.goto_hash("#/feed?src=" + quote(source), "#list .item")
|
||||||
|
rows_src = await page.locator("#list .item").count()
|
||||||
|
shown = await page.evaluate(
|
||||||
|
"""() => [...document.querySelectorAll('#list .item .feed-content .small > span:first-child')]
|
||||||
|
.slice(0, 25).map(e => e.textContent.trim())"""
|
||||||
|
)
|
||||||
|
mismatched = [s for s in shown if s != source]
|
||||||
|
self.out["scenarios"]["deep_link"] = {
|
||||||
|
"rows_all": rows_all,
|
||||||
|
"source": source[:40],
|
||||||
|
"rows_filtered": rows_src,
|
||||||
|
"mismatched_rows": len(mismatched),
|
||||||
|
}
|
||||||
|
assert rows_src > 0, f"src deep-link returned no rows for {source!r}"
|
||||||
|
assert not mismatched, f"src deep-link leaked other sources: {mismatched[:3]}"
|
||||||
|
# Resetting the filter must restore the full feed.
|
||||||
|
await self.goto_hash("#/feed", "#list .item")
|
||||||
|
rows_reset = await page.locator("#list .item").count()
|
||||||
|
self.out["scenarios"]["deep_link"]["rows_after_reset"] = rows_reset
|
||||||
|
assert rows_reset >= rows_src, "resetting the deep-link filter shrank the feed"
|
||||||
|
|
||||||
|
async def exports(self):
|
||||||
|
page = self.page
|
||||||
|
await self.goto_hash("#/feed", "#list .item")
|
||||||
|
csv_href = await page.locator("#export-csv").get_attribute("href", timeout=T)
|
||||||
|
json_href = await page.locator("#export-json").get_attribute("href", timeout=T)
|
||||||
|
sizes: dict[str, int] = {}
|
||||||
|
for key, href in (("csv", csv_href), ("json", json_href)):
|
||||||
|
assert href and href.startswith("/api/export"), f"export {key} href missing: {href}"
|
||||||
|
resp = await page.request.get(self.url + href, timeout=30000)
|
||||||
|
assert resp.status == 200, f"export {key} -> HTTP {resp.status}"
|
||||||
|
body = await resp.body()
|
||||||
|
sizes[key] = len(body)
|
||||||
|
assert len(body) > 200, f"export {key} suspiciously small: {len(body)} bytes"
|
||||||
|
payload = await page.evaluate(
|
||||||
|
"""async (href) => { const r = await fetch(href); const j = await r.json();
|
||||||
|
return {ok: j.ok, total: j.total, count: j.count,
|
||||||
|
first: (j.items && j.items[0]) ? Object.keys(j.items[0]).slice(0, 8) : []}; }""",
|
||||||
|
json_href,
|
||||||
|
)
|
||||||
|
assert payload["count"] > 0 and payload["first"], f"export json invalid: {payload}"
|
||||||
|
assert "title" in payload["first"], f"export json lost columns: {payload['first']}"
|
||||||
|
self.out["scenarios"]["exports"] = {"bytes": sizes, "json": payload, "csv_href": (csv_href or "")[:70]}
|
||||||
|
|
||||||
|
async def reader(self):
|
||||||
|
page = self.page
|
||||||
|
await self.goto_hash("#/feed", "#list .item")
|
||||||
|
opener = page.locator("#list .item").first.locator("[data-id]").first
|
||||||
|
item_id = await opener.get_attribute("data-id", timeout=T)
|
||||||
|
assert item_id, "feed row has no data-id"
|
||||||
|
async with page.expect_response(lambda r: f"/api/item/{item_id}" in r.url, timeout=25000) as detail:
|
||||||
|
await opener.click(timeout=T)
|
||||||
|
resp = await detail.value
|
||||||
|
await page.wait_for_selector("#drawer.show", timeout=15000)
|
||||||
|
body_len = await page.locator("#d-body").evaluate("el => el.textContent.trim().length")
|
||||||
|
title = (await page.locator("#d-title").inner_text(timeout=T)).strip()
|
||||||
|
payload = await resp.json()
|
||||||
|
api_url = (payload.get("item") or payload).get("url") or ""
|
||||||
|
link_href = await page.locator('#drawer a[target="_blank"]').first.get_attribute("href", timeout=T)
|
||||||
|
self.out["scenarios"]["reader"] = {
|
||||||
|
"id": item_id,
|
||||||
|
"api_status": resp.status,
|
||||||
|
"title": title[:60],
|
||||||
|
"body_chars": body_len,
|
||||||
|
"api_url": api_url[:70],
|
||||||
|
"original_link": (link_href or "")[:70],
|
||||||
|
}
|
||||||
|
assert resp.status == 200, f"item api -> HTTP {resp.status}"
|
||||||
|
assert title, "reader opened without a title"
|
||||||
|
assert body_len > 40, f"reader body empty for {item_id}"
|
||||||
|
if api_url:
|
||||||
|
assert link_href == api_url, f"original link {link_href!r} != api url {api_url!r}"
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await page.wait_for_timeout(600)
|
||||||
|
assert not await page.evaluate("document.querySelector('#drawer').classList.contains('show')"), \
|
||||||
|
"Escape did not close the reader"
|
||||||
|
await self.no_collision("reader closed")
|
||||||
|
|
||||||
|
async def digest(self):
|
||||||
|
page = self.page
|
||||||
|
await self.goto_hash("#/digest")
|
||||||
|
await page.locator('[data-act="digest"]').first.click(timeout=T)
|
||||||
|
await page.wait_for_timeout(4000)
|
||||||
|
html = await page.locator("#view").inner_html(timeout=T)
|
||||||
|
leaked = "ai_not_configured" in html and "ИИ не настроен" not in html
|
||||||
|
self.out["scenarios"]["digest"] = {
|
||||||
|
"rendered_chars": len(html),
|
||||||
|
"has_markdown": "```" in html or "<pre" in html,
|
||||||
|
"friendly_hint": "ИИ не настроен" in html,
|
||||||
|
"raw_error_leak": leaked,
|
||||||
|
}
|
||||||
|
assert not leaked, "digest leaked the raw backend error code to the operator"
|
||||||
|
assert len(html) > 200, "digest rendered nothing"
|
||||||
|
|
||||||
|
async def sources_and_telegram(self):
|
||||||
|
page = self.page
|
||||||
|
await self.goto_hash("#/sources", "#src-tbl")
|
||||||
|
# The table paints a placeholder row first; wait for the real registry.
|
||||||
|
await page.wait_for_function(
|
||||||
|
"document.querySelectorAll('#src-tbl tbody tr').length > 5", timeout=25000
|
||||||
|
)
|
||||||
|
rows_all = await page.locator("#src-tbl tbody tr").count()
|
||||||
|
needle = "telegram"
|
||||||
|
await page.locator("#s-q").fill(needle, timeout=T)
|
||||||
|
await page.wait_for_timeout(1800)
|
||||||
|
await self.settle()
|
||||||
|
rows_filtered = await page.locator("#src-tbl tbody tr").count()
|
||||||
|
visible = await page.evaluate(
|
||||||
|
"""() => [...document.querySelectorAll('#src-tbl tbody tr')]
|
||||||
|
.slice(0, 25).map(tr => tr.textContent.toLowerCase())"""
|
||||||
|
)
|
||||||
|
off_topic = [row[:40] for row in visible if needle not in row]
|
||||||
|
self.out["scenarios"]["source_search"] = {
|
||||||
|
"rows_all": rows_all,
|
||||||
|
"needle": needle,
|
||||||
|
"rows_filtered": rows_filtered,
|
||||||
|
"off_topic_rows": len(off_topic),
|
||||||
|
}
|
||||||
|
assert rows_all > 100, f"source registry looks empty: {rows_all} rows"
|
||||||
|
assert 0 < rows_filtered < rows_all, f"source search broken: {rows_all} -> {rows_filtered}"
|
||||||
|
assert not off_topic, f"source search returned unrelated rows: {off_topic[:3]}"
|
||||||
|
|
||||||
|
await self.goto_hash("#/tg", "#tg-search-form")
|
||||||
|
status = await page.evaluate(
|
||||||
|
"async () => { const r = await fetch('/api/tg/status'); return {status: r.status, body: await r.json()}; }"
|
||||||
|
)
|
||||||
|
body = status["body"]
|
||||||
|
me = body.get("me") or {}
|
||||||
|
text = (await page.locator("#view").inner_text(timeout=T)).strip()
|
||||||
|
self.out["scenarios"]["telegram"] = {
|
||||||
|
"http": status["status"],
|
||||||
|
"enabled": body.get("enabled"),
|
||||||
|
"error": body.get("error"),
|
||||||
|
"account": me.get("first_name"),
|
||||||
|
"account_id": me.get("id"),
|
||||||
|
"session": body.get("session"),
|
||||||
|
"rendered_chars": len(text),
|
||||||
|
}
|
||||||
|
assert status["status"] == 200, f"tg status -> HTTP {status['status']}"
|
||||||
|
assert body.get("enabled") is True and not body.get("error"), f"tg session degraded: {body}"
|
||||||
|
assert me.get("first_name") and me.get("id"), f"tg account missing: {body}"
|
||||||
|
# the tab mounts a "Загрузка данных…" stub first; a real render is far longer
|
||||||
|
assert len(text) > 200, f"Telegram tab rendered a stub: {len(text)} chars -> {text[:80]!r}"
|
||||||
|
|
||||||
|
async def mobile(self):
|
||||||
|
page = self.page
|
||||||
|
await page.set_viewport_size({"width": 390, "height": 860})
|
||||||
|
# A query before the hash forces a real document load; hash-only goto is same-document.
|
||||||
|
await page.goto(self.url + "/?v=mobile#/feed", wait_until="domcontentloaded", timeout=30000)
|
||||||
|
await page.wait_for_selector("#list .item", timeout=25000)
|
||||||
|
await self.settle()
|
||||||
|
await asyncio.wait_for(page.locator("#sidebar-toggle").click(timeout=T), 25)
|
||||||
|
await page.wait_for_selector("#sidebar-menu.show:not(.collapsing)", timeout=10000)
|
||||||
|
await page.wait_for_timeout(400)
|
||||||
|
# Everything painted must be tappable. find_covered skips hidden nodes and closed
|
||||||
|
# <details> groups, so a footer or overlay covering a real control fails the run.
|
||||||
|
covered = await find_covered(
|
||||||
|
page, "#sidebar-menu [data-nav], .sidebar-footer button, #sidebar-toggle", "nav"
|
||||||
|
)
|
||||||
|
geometry = await page.evaluate(
|
||||||
|
"""() => { const nav = document.querySelector('#nav').getBoundingClientRect();
|
||||||
|
const foot = document.querySelector('.sidebar-footer').getBoundingClientRect();
|
||||||
|
return {navBottom: Math.round(nav.bottom), footerTop: Math.round(foot.top),
|
||||||
|
menuHeight: Math.round(document.querySelector('#sidebar-menu').getBoundingClientRect().height),
|
||||||
|
menuInlineHeight: document.querySelector('#sidebar-menu').style.height || ''}; }"""
|
||||||
|
)
|
||||||
|
# «Обзор» lives in the collapsible «Система» group: open it the way a user would.
|
||||||
|
await asyncio.wait_for(page.locator("#system-nav > summary").click(timeout=T), 25)
|
||||||
|
await page.wait_for_function("document.querySelector('#system-nav').open", timeout=10000)
|
||||||
|
covered_system = await find_covered(page, "#system-nav [data-nav]", "nav")
|
||||||
|
labels = [t.strip() for t in await page.locator("#sidebar-menu [data-nav]").all_inner_texts()]
|
||||||
|
# A real click hangs forever when something is painted over the target - that is the point.
|
||||||
|
await asyncio.wait_for(page.locator('#sidebar-menu [data-nav="overview"]').click(timeout=T), 30)
|
||||||
|
await page.wait_for_timeout(1200)
|
||||||
|
hash_after = await page.evaluate("location.hash")
|
||||||
|
closed = await page.evaluate("!document.querySelector('#sidebar-menu').classList.contains('show')")
|
||||||
|
await self.no_collision("mobile overview")
|
||||||
|
self.out["scenarios"]["mobile"] = {
|
||||||
|
"nav_labels": labels[:12],
|
||||||
|
"covered_controls": covered,
|
||||||
|
"covered_system_group": covered_system,
|
||||||
|
"sidebar_geometry": geometry,
|
||||||
|
"hash_after": hash_after,
|
||||||
|
"menu_closed_after_nav": closed,
|
||||||
|
}
|
||||||
|
assert not covered, f"mobile menu has untappable controls: {covered}"
|
||||||
|
assert not covered_system, f"«Система» group has untappable items: {covered_system}"
|
||||||
|
assert geometry["navBottom"] <= geometry["footerTop"] + 1, f"nav overlaps footer: {geometry}"
|
||||||
|
assert "overview" in hash_after, f"mobile navigation did not route: {hash_after}"
|
||||||
|
assert closed, "offcanvas stayed open after navigation"
|
||||||
|
assert labels and all(labels), "mobile navigation lost its labels"
|
||||||
|
await page.screenshot(path=str(self.shots / "mobile_overview.png"))
|
||||||
|
await page.set_viewport_size({"width": 1440, "height": 960})
|
||||||
|
|
||||||
|
async def add_source(self):
|
||||||
|
"""Reject an invalid source through the real form: no mutation, friendly message."""
|
||||||
|
page = self.page
|
||||||
|
await page.set_viewport_size({"width": 1440, "height": 960})
|
||||||
|
await self.goto_hash("#/sources", "#src-tbl")
|
||||||
|
await page.wait_for_function(
|
||||||
|
"document.querySelectorAll('#src-tbl tbody tr').length > 5", timeout=25000
|
||||||
|
)
|
||||||
|
rows_before = await page.locator("#src-tbl tbody tr").count()
|
||||||
|
await asyncio.wait_for(page.locator('[data-act="add-src"]').click(timeout=T), 15)
|
||||||
|
await page.wait_for_selector("#modal.show #add-source-form", timeout=10000)
|
||||||
|
out = {"rows_before": rows_before}
|
||||||
|
# The submit button sits in the modal footer and is wired to the form by id,
|
||||||
|
# so "#add-source-form button[type=submit]" matches nothing.
|
||||||
|
submit = page.locator('#modal-box button[type="submit"][form="add-source-form"]')
|
||||||
|
out["covered_modal_controls"] = await find_covered(
|
||||||
|
page, "#modal.show button, #modal.show input, #modal.show select", None
|
||||||
|
)
|
||||||
|
# 1) empty required fields: native validation must block the write
|
||||||
|
await asyncio.wait_for(submit.click(timeout=T), 15)
|
||||||
|
await page.wait_for_timeout(600)
|
||||||
|
out["url_invalid_when_empty"] = await page.evaluate(
|
||||||
|
"!document.querySelector('#a-url').checkValidity()"
|
||||||
|
)
|
||||||
|
out["error_after_empty_submit"] = (
|
||||||
|
await page.locator("#add-source-error").inner_text()
|
||||||
|
).strip()
|
||||||
|
# 2) a non-http scheme passes type=url but must be refused by the app
|
||||||
|
await page.fill("#a-name", "scenario-probe-do-not-add")
|
||||||
|
await page.fill("#a-url", "ftp://example.invalid/feed")
|
||||||
|
await asyncio.wait_for(submit.click(timeout=T), 15)
|
||||||
|
await page.wait_for_function(
|
||||||
|
"document.querySelector('#add-source-error').textContent.trim().length > 0", timeout=10000
|
||||||
|
)
|
||||||
|
out["error_text"] = (await page.locator("#add-source-error").inner_text()).strip()[:160]
|
||||||
|
out["modal_still_open"] = await page.evaluate(
|
||||||
|
"document.querySelector('#modal').classList.contains('show')"
|
||||||
|
)
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await page.wait_for_function(
|
||||||
|
"!document.querySelector('#modal').classList.contains('show')", timeout=10000
|
||||||
|
)
|
||||||
|
await self.goto_hash("#/sources", "#src-tbl")
|
||||||
|
await page.wait_for_function(
|
||||||
|
"document.querySelectorAll('#src-tbl tbody tr').length > 5", timeout=25000
|
||||||
|
)
|
||||||
|
out["rows_after"] = await page.locator("#src-tbl tbody tr").count()
|
||||||
|
self.out["scenarios"]["add_source"] = out
|
||||||
|
assert out["url_invalid_when_empty"], "empty URL passed native validation"
|
||||||
|
assert not out["error_after_empty_submit"], "native validation leaked an app error"
|
||||||
|
assert "http" in out["error_text"].lower(), f"no friendly validation message: {out['error_text']!r}"
|
||||||
|
assert "не удалось загрузить" not in out["error_text"].lower(), (
|
||||||
|
f"validation error disguised as a load failure: {out['error_text']!r}"
|
||||||
|
)
|
||||||
|
assert out["modal_still_open"], "modal closed despite the rejected source"
|
||||||
|
assert not out["covered_modal_controls"], f"modal controls untappable: {out['covered_modal_controls']}"
|
||||||
|
assert out["rows_after"] == rows_before, (
|
||||||
|
f"rejected source leaked into the registry: {rows_before} -> {out['rows_after']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def keyboard(self):
|
||||||
|
"""Keyboard-only user: / search, Esc, ? help, digit navigation."""
|
||||||
|
page = self.page
|
||||||
|
await page.set_viewport_size({"width": 1440, "height": 960})
|
||||||
|
await self.goto_hash("#/feed", "#list .item")
|
||||||
|
out = {"rows_before": await page.locator("#list .item").count()}
|
||||||
|
await page.keyboard.press("/")
|
||||||
|
out["slash_focus"] = await page.evaluate("document.activeElement?.id || ''")
|
||||||
|
needle = "telethon" # rare enough that a filtered page is smaller than the default one
|
||||||
|
await page.keyboard.type(needle, delay=40)
|
||||||
|
await page.wait_for_timeout(900)
|
||||||
|
out["suggestions"] = await page.evaluate("document.querySelectorAll('#sug button').length")
|
||||||
|
# Typing only fills the box: the list filters when the feed form is submitted.
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await page.wait_for_selector("#list .item", timeout=25000)
|
||||||
|
await page.wait_for_timeout(1200)
|
||||||
|
out["query"] = await page.input_value("#q")
|
||||||
|
out["hash_after_query"] = await page.evaluate("location.hash")
|
||||||
|
out["rows_after_query"] = await page.locator("#list .item").count()
|
||||||
|
# A match can live in the body text, so compare the rendered ids with the API answer
|
||||||
|
# instead of looking for the needle in visible text.
|
||||||
|
out["ui_ids"] = await page.evaluate(
|
||||||
|
"[...document.querySelectorAll('#list .item .reader-open')].map(b => Number(b.dataset.id))"
|
||||||
|
)
|
||||||
|
out["api_ids"] = await page.evaluate(
|
||||||
|
"""async q => {
|
||||||
|
const r = await fetch('/api/items?q=' + encodeURIComponent(q) + '&limit=40&sort=fresh');
|
||||||
|
const d = await r.json();
|
||||||
|
return (d.items || []).map(i => Number(i.id));
|
||||||
|
}""",
|
||||||
|
needle,
|
||||||
|
)
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await page.wait_for_timeout(600)
|
||||||
|
out["suggestions_hidden"] = await page.evaluate(
|
||||||
|
"const s=document.querySelector('#sug'); !s || s.classList.contains('d-none')"
|
||||||
|
)
|
||||||
|
await page.evaluate("document.activeElement && document.activeElement.blur()")
|
||||||
|
await page.keyboard.press("?")
|
||||||
|
await page.wait_for_selector("#modal.show", timeout=10000)
|
||||||
|
# Bootstrap drops Escape during the fade-in transition
|
||||||
|
await page.wait_for_timeout(450)
|
||||||
|
out["help_title"] = (await page.locator("#modal-title").inner_text()).strip()
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await page.wait_for_function(
|
||||||
|
"!document.querySelector('#modal').classList.contains('show')", timeout=10000
|
||||||
|
)
|
||||||
|
await page.keyboard.press("3")
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
out["hash_after_3"] = await page.evaluate("location.hash")
|
||||||
|
await page.keyboard.press("2")
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
out["hash_after_2"] = await page.evaluate("location.hash")
|
||||||
|
await self.no_collision("keyboard flow")
|
||||||
|
self.out["scenarios"]["keyboard"] = out
|
||||||
|
assert out["slash_focus"] == "q", f"/ did not focus search: {out['slash_focus']!r}"
|
||||||
|
assert out["query"] == needle, f"search box lost the query: {out}"
|
||||||
|
assert "q=" + needle in out["hash_after_query"], f"query did not reach the route: {out}"
|
||||||
|
assert 0 < out["rows_after_query"] < out["rows_before"], f"search did not filter: {out}"
|
||||||
|
assert out["ui_ids"] and set(out["ui_ids"]) == set(out["api_ids"]), (
|
||||||
|
f"rendered rows differ from the API answer: {out['ui_ids'][:6]} vs {out['api_ids'][:6]}"
|
||||||
|
)
|
||||||
|
assert out["suggestions_hidden"], "suggestions stayed open after Escape"
|
||||||
|
assert out["help_title"], "help modal opened without a title"
|
||||||
|
assert "abuse" in out["hash_after_3"], f"digit 3 did not switch section: {out['hash_after_3']}"
|
||||||
|
assert "feed" in out["hash_after_2"], f"digit 2 did not return to feed: {out['hash_after_2']}"
|
||||||
|
|
||||||
|
async def adaptivity(self):
|
||||||
|
"""Narrow and wide screens: no horizontal scrolling, no covered controls."""
|
||||||
|
page = self.page
|
||||||
|
routes = ("feed", "sources", "abuse", "overview")
|
||||||
|
results = {}
|
||||||
|
for width in (320, 390, 768, 1024, 1280, 1920):
|
||||||
|
await page.set_viewport_size({"width": width, "height": 900})
|
||||||
|
# query before the hash forces a real document load for this width
|
||||||
|
await page.goto(
|
||||||
|
f"{self.url}/?v=w{width}#/feed", wait_until="domcontentloaded", timeout=30000
|
||||||
|
)
|
||||||
|
await page.wait_for_selector("#list .item", timeout=25000)
|
||||||
|
per_route = {}
|
||||||
|
for route in routes:
|
||||||
|
await self.goto_hash(f"#/{route}")
|
||||||
|
await page.wait_for_timeout(900)
|
||||||
|
per_route[route] = await page.evaluate(
|
||||||
|
"""() => {
|
||||||
|
const contained = el => {
|
||||||
|
for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
|
||||||
|
const ox = getComputedStyle(p).overflowX;
|
||||||
|
if ((ox === 'auto' || ox === 'scroll' || ox === 'hidden')
|
||||||
|
&& p.getBoundingClientRect().right <= window.innerWidth + 1) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
return {scrollW: document.documentElement.scrollWidth,
|
||||||
|
inner: window.innerWidth,
|
||||||
|
spilled: [...document.querySelectorAll('#view *')]
|
||||||
|
.filter(e => e.getBoundingClientRect().right > window.innerWidth + 1
|
||||||
|
&& !contained(e))
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(e => (e.id ? '#' + e.id : '') + '.'
|
||||||
|
+ (e.className.toString().slice(0, 40)))};
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
results[width] = per_route
|
||||||
|
if width in (320, 768):
|
||||||
|
await self.goto_hash("#/feed", "#list .item")
|
||||||
|
results[f"covered@{width}"] = await find_covered(
|
||||||
|
page, "#page-actions .btn, #view .nav-tabs .nav-link, #q, #sidebar-toggle", None
|
||||||
|
)
|
||||||
|
self.out["scenarios"]["adaptivity"] = results
|
||||||
|
overflow = [
|
||||||
|
{"width": width, "route": route, **m}
|
||||||
|
for width, per_route in results.items()
|
||||||
|
if isinstance(per_route, dict)
|
||||||
|
for route, m in per_route.items()
|
||||||
|
if m["scrollW"] > m["inner"] + 1 or m["spilled"]
|
||||||
|
]
|
||||||
|
covered = {k: v for k, v in results.items() if str(k).startswith("covered@") and v}
|
||||||
|
assert not overflow, f"horizontal overflow: {overflow[:4]}"
|
||||||
|
assert not covered, f"untappable controls on narrow screens: {covered}"
|
||||||
|
await page.set_viewport_size({"width": 1440, "height": 960})
|
||||||
|
|
||||||
|
async def zoom(self):
|
||||||
|
page = self.page
|
||||||
|
await page.set_viewport_size({"width": 1440, "height": 960})
|
||||||
|
for zoom in ("1", "1.25", "1.5"):
|
||||||
|
await page.evaluate(f"document.documentElement.style.zoom='{zoom}'")
|
||||||
|
for route in ("feed", "sources", "abuse", "overview"):
|
||||||
|
await self.goto_hash(f"#/{route}")
|
||||||
|
await page.wait_for_timeout(700)
|
||||||
|
await self.no_collision(f"{route} @ zoom {zoom}")
|
||||||
|
await page.evaluate("document.documentElement.style.zoom=''")
|
||||||
|
|
||||||
|
async def screenshots(self):
|
||||||
|
page = self.page
|
||||||
|
await page.set_viewport_size({"width": 1440, "height": 960})
|
||||||
|
await self.goto_hash("#/feed", "#list .item")
|
||||||
|
await page.screenshot(path=str(self.shots / "scenarios_feed.png"))
|
||||||
|
await self.goto_hash("#/sources", "#src-tbl")
|
||||||
|
await page.screenshot(path=str(self.shots / "scenarios_sources.png"))
|
||||||
|
await self.goto_hash("#/overview")
|
||||||
|
await page.wait_for_timeout(1200)
|
||||||
|
await page.screenshot(path=str(self.shots / "scenarios_overview.png"))
|
||||||
|
await page.set_viewport_size({"width": 390, "height": 860})
|
||||||
|
await self.goto_hash("#/feed", "#list .item")
|
||||||
|
await page.screenshot(path=str(self.shots / "scenarios_mobile.png"))
|
||||||
|
await page.set_viewport_size({"width": 1440, "height": 960})
|
||||||
|
|
||||||
|
|
||||||
|
SCENARIOS = [
|
||||||
|
("deep_link", 90),
|
||||||
|
("exports", 90),
|
||||||
|
("reader", 90),
|
||||||
|
("digest", 90),
|
||||||
|
("sources_and_telegram", 90),
|
||||||
|
("add_source", 90),
|
||||||
|
("keyboard", 120),
|
||||||
|
("mobile", 90),
|
||||||
|
("adaptivity", 240),
|
||||||
|
("zoom", 150),
|
||||||
|
("screenshots", 90),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def run(url: str, shots: pathlib.Path) -> dict:
|
||||||
|
shots.mkdir(parents=True, exist_ok=True)
|
||||||
|
async with async_playwright() as pw:
|
||||||
|
browser = await pw.chromium.launch()
|
||||||
|
runner: Runner | None = None
|
||||||
|
try:
|
||||||
|
page = await browser.new_page(viewport={"width": 1440, "height": 960})
|
||||||
|
page.set_default_timeout(T)
|
||||||
|
page.set_default_navigation_timeout(30000)
|
||||||
|
runner = Runner(page, url, shots)
|
||||||
|
page.on("pageerror", lambda e: runner.errors.append(f"pageerror: {e}"))
|
||||||
|
page.on(
|
||||||
|
"console",
|
||||||
|
lambda m: runner.errors.append(f"console: {m.text}") if m.type == "error" else None,
|
||||||
|
)
|
||||||
|
for name, budget in SCENARIOS:
|
||||||
|
runner.out["stage"] = name
|
||||||
|
print("[scenario]", name, flush=True)
|
||||||
|
await asyncio.wait_for(getattr(runner, name)(), budget)
|
||||||
|
runner.out["stage"] = "done"
|
||||||
|
runner.out["page_errors"] = runner.errors[:8]
|
||||||
|
assert not runner.errors, f"browser errors during scenarios: {runner.errors[:4]}"
|
||||||
|
except BaseException as exc: # keep the report even when a scenario blows up
|
||||||
|
if runner is not None:
|
||||||
|
runner.out["error"] = f"{type(exc).__name__}: {exc}"[:400]
|
||||||
|
runner.out["page_errors"] = runner.errors[:8]
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if runner is not None:
|
||||||
|
(shots / "report.json").write_text(
|
||||||
|
json.dumps(runner.out, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
print(json.dumps(runner.out, ensure_ascii=False, indent=2))
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(browser.close(), timeout=20)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return runner.out if runner else {}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--url", default=BASE)
|
||||||
|
parser.add_argument("--shots", default="data/shots/scenarios")
|
||||||
|
args = parser.parse_args()
|
||||||
|
asyncio.run(run(args.url, pathlib.Path(args.shots)))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
56
tools/reachability.py
Normal file
56
tools/reachability.py
Normal 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})
|
||||||
@@ -12,6 +12,9 @@ from urllib.parse import parse_qs, quote, urljoin, urlsplit
|
|||||||
|
|
||||||
from playwright.async_api import async_playwright
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
||||||
|
from reachability import find_covered # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
async def run(url, out):
|
async def run(url, out):
|
||||||
out.mkdir(parents=True, exist_ok=True)
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -96,6 +99,12 @@ async def run(url, out):
|
|||||||
assert not hits, f"Text painted on text at {label}: {hits}"
|
assert not hits, f"Text painted on text at {label}: {hits}"
|
||||||
report.setdefault("no_text_collision", []).append(label)
|
report.setdefault("no_text_collision", []).append(label)
|
||||||
|
|
||||||
|
async def assert_reachable(label, selector, key_attr=None):
|
||||||
|
"""Fail when a visible control is painted over: a human could not click it."""
|
||||||
|
covered = await find_covered(page, selector, key_attr)
|
||||||
|
assert not covered, f"Unreachable controls at {label}: {covered}"
|
||||||
|
report.setdefault("reachable", []).append(label)
|
||||||
|
|
||||||
async def assert_dark_theme(label):
|
async def assert_dark_theme(label):
|
||||||
colors = await page.evaluate("""() => {
|
colors = await page.evaluate("""() => {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
@@ -130,6 +139,11 @@ async def run(url, out):
|
|||||||
assert report["navigation_routes"] == 9
|
assert report["navigation_routes"] == 9
|
||||||
await assert_no_overflow("desktop")
|
await assert_no_overflow("desktop")
|
||||||
await assert_no_text_collision("feed 1440")
|
await assert_no_text_collision("feed 1440")
|
||||||
|
await assert_reachable("desktop rail 1440", "#sidebar-menu [data-nav]", "nav")
|
||||||
|
await assert_reachable(
|
||||||
|
"desktop controls 1440",
|
||||||
|
"#page-actions .btn, #view .nav-tabs .nav-link, #export-csv, #export-json, #q",
|
||||||
|
)
|
||||||
await assert_dark_theme("feed")
|
await assert_dark_theme("feed")
|
||||||
step("feed")
|
step("feed")
|
||||||
await page.screenshot(path=str(out / "black_feed.png"))
|
await page.screenshot(path=str(out / "black_feed.png"))
|
||||||
@@ -268,6 +282,11 @@ async def run(url, out):
|
|||||||
await page.locator("#sidebar-toggle").click()
|
await page.locator("#sidebar-toggle").click()
|
||||||
await page.locator("#sidebar-menu").wait_for(state="visible")
|
await page.locator("#sidebar-menu").wait_for(state="visible")
|
||||||
assert await page.locator('[data-nav="sources"]').is_visible()
|
assert await page.locator('[data-nav="sources"]').is_visible()
|
||||||
|
await assert_reachable(
|
||||||
|
"mobile menu 390",
|
||||||
|
"#sidebar-menu [data-nav], .sidebar-footer button, #sidebar-toggle",
|
||||||
|
"nav",
|
||||||
|
)
|
||||||
await assert_no_overflow("mobile navigation 390")
|
await assert_no_overflow("mobile navigation 390")
|
||||||
await assert_dark_theme("mobile navigation")
|
await assert_dark_theme("mobile navigation")
|
||||||
await page.screenshot(path=str(out / "black_mobile_navigation.png"))
|
await page.screenshot(path=str(out / "black_mobile_navigation.png"))
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ a:hover { color: var(--omni-foreground); }
|
|||||||
.navbar-vertical .nav-link-icon .icon { width: var(--omni-space-4); height: var(--omni-space-4); }
|
.navbar-vertical .nav-link-icon .icon { width: var(--omni-space-4); height: var(--omni-space-4); }
|
||||||
.navbar details > summary { cursor: pointer; }
|
.navbar details > summary { cursor: pointer; }
|
||||||
.navbar details[open] > summary { margin-bottom: var(--omni-space-2); }
|
.navbar details[open] > summary { margin-bottom: var(--omni-space-2); }
|
||||||
.sidebar-footer { padding: var(--omni-space-4) var(--omni-space-3); }
|
.sidebar-footer { flex: 0 0 auto; padding: var(--omni-space-4) var(--omni-space-3); }
|
||||||
#health { line-height: 1.6; }
|
#health { line-height: 1.6; }
|
||||||
.sidebar-footer .btn { padding-inline: 0; white-space: normal; text-align: start; }
|
.sidebar-footer .btn { padding-inline: 0; white-space: normal; text-align: start; }
|
||||||
#sidebar-toggle { position: static; inset: auto; transform: none; width: auto; min-width: 5.5rem; height: auto; min-height: var(--omni-control-height); flex: 0 0 auto; gap: var(--omni-space-2); padding: var(--omni-space-2) var(--omni-space-3); border: 1px solid var(--omni-border); border-radius: var(--omni-radius); color: var(--omni-foreground); font-size: var(--omni-text-base); background: var(--omni-background); box-shadow: none; }
|
#sidebar-toggle { position: static; inset: auto; transform: none; width: auto; min-width: 5.5rem; height: auto; min-height: var(--omni-control-height); flex: 0 0 auto; gap: var(--omni-space-2); padding: var(--omni-space-2) var(--omni-space-3); border: 1px solid var(--omni-border); border-radius: var(--omni-radius); color: var(--omni-foreground); font-size: var(--omni-text-base); background: var(--omni-background); box-shadow: none; }
|
||||||
@@ -277,15 +277,19 @@ pre { padding: var(--omni-space-4); }
|
|||||||
@media (min-width: 992px) {
|
@media (min-width: 992px) {
|
||||||
.navbar.navbar-vertical > .container-fluid { height: 100%; padding: var(--omni-space-6) var(--omni-space-2) 0; }
|
.navbar.navbar-vertical > .container-fluid { height: 100%; padding: var(--omni-space-6) var(--omni-space-2) 0; }
|
||||||
.navbar.navbar-vertical .navbar-brand { padding: 0 var(--omni-space-3) var(--omni-space-6); }
|
.navbar.navbar-vertical .navbar-brand { padding: 0 var(--omni-space-3) var(--omni-space-6); }
|
||||||
#sidebar-menu { flex: 1; align-items: stretch; }
|
#sidebar-menu { display: flex; flex-direction: column; flex: 1; align-items: stretch; min-height: 0; }
|
||||||
|
#sidebar-menu > #nav { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
|
||||||
}
|
}
|
||||||
@media (max-width: 991.98px) {
|
@media (max-width: 991.98px) {
|
||||||
.navbar.navbar-vertical { border-inline-end: 0; border-bottom: 1px solid var(--omni-border); }
|
.navbar.navbar-vertical { border-inline-end: 0; border-bottom: 1px solid var(--omni-border); }
|
||||||
.navbar.navbar-vertical > .container-fluid { padding: var(--omni-space-3) var(--omni-space-4); justify-content: space-between; flex-wrap: wrap; }
|
.navbar.navbar-vertical > .container-fluid { padding: var(--omni-space-3) var(--omni-space-4); justify-content: space-between; flex-wrap: wrap; }
|
||||||
.navbar.navbar-vertical .navbar-brand { flex: 1; padding: 0; }
|
.navbar.navbar-vertical .navbar-brand { flex: 1; padding: 0; }
|
||||||
#sidebar-menu { flex-basis: 100%; }
|
#sidebar-menu { flex-basis: 100%; flex-direction: column; align-items: stretch; }
|
||||||
|
/* Без фикс-высоты после раскрытия: иначе список уезжает под футер и пункты не нажимаются. */
|
||||||
|
#sidebar-menu.show:not(.collapsing) { height: auto !important; max-height: none !important; overflow: visible !important; }
|
||||||
|
#sidebar-menu > #nav { flex: 0 0 auto; overflow: visible; }
|
||||||
.navbar-vertical .navbar-nav { padding-inline: 0; }
|
.navbar-vertical .navbar-nav { padding-inline: 0; }
|
||||||
.sidebar-footer { margin-top: var(--omni-space-4) !important; padding-inline: 0; }
|
.sidebar-footer { flex: 0 0 auto; margin-top: var(--omni-space-4) !important; padding-inline: 0; }
|
||||||
.page-wrapper .container-xl { padding-inline: var(--omni-space-6); }
|
.page-wrapper .container-xl { padding-inline: var(--omni-space-6); }
|
||||||
}
|
}
|
||||||
@media (max-width: 575.98px) {
|
@media (max-width: 575.98px) {
|
||||||
@@ -407,7 +411,7 @@ async function api(path,opt={}){
|
|||||||
const post=(path,body)=>api(path,{method:"POST",body:JSON.stringify(body||{})});
|
const post=(path,body)=>api(path,{method:"POST",body:JSON.stringify(body||{})});
|
||||||
async function copy(value){if(!value){toast("Нет данных для копирования",true);return}try{if(!navigator.clipboard)throw new Error("clipboard");await navigator.clipboard.writeText(value);toast("Скопировано")}catch{toast("Не удалось скопировать. Разрешите доступ к буферу обмена.",true)}}
|
async function copy(value){if(!value){toast("Нет данных для копирования",true);return}try{if(!navigator.clipboard)throw new Error("clipboard");await navigator.clipboard.writeText(value);toast("Скопировано")}catch{toast("Не удалось скопировать. Разрешите доступ к буферу обмена.",true)}}
|
||||||
const retries=new Map();let retrySerial=0;
|
const retries=new Map();let retrySerial=0;
|
||||||
function failure(error,retry){const id=String(++retrySerial);if(retry){retries.set(id,retry);if(retries.size>40)retries.delete(retries.keys().next().value)}return `<div class="alert alert-danger mb-0" role="alert"><h3 class="alert-title">Не удалось загрузить данные</h3><div class="text-break">${text(error.message||error)}</div>${retry?`<button class="btn btn-sm mt-3" type="button" data-retry="${id}">Повторить</button>`:""}</div>`}
|
function failure(error,retry,title="Не удалось загрузить данные"){const id=String(++retrySerial);if(retry){retries.set(id,retry);if(retries.size>40)retries.delete(retries.keys().next().value)}return `<div class="alert alert-danger mb-0" role="alert"><h3 class="alert-title">${esc(title)}</h3><div class="text-break">${text(error.message||error)}</div>${retry?`<button class="btn btn-sm mt-3" type="button" data-retry="${id}">Повторить</button>`:""}</div>`}
|
||||||
function loading(label="Загрузка данных…",rows=3){return `<div role="status" class="p-4"><div class="d-flex align-items-center gap-2 text-secondary"><span class="spinner-border spinner-border-sm" aria-hidden="true"></span>${esc(label)}</div><div class="placeholder-glow mt-3" aria-hidden="true">${Array(rows).fill('<div class="mb-3"><span class="placeholder col-8"></span><span class="placeholder col-5 d-block mt-2"></span></div>').join("")}</div></div>`}
|
function loading(label="Загрузка данных…",rows=3){return `<div role="status" class="p-4"><div class="d-flex align-items-center gap-2 text-secondary"><span class="spinner-border spinner-border-sm" aria-hidden="true"></span>${esc(label)}</div><div class="placeholder-glow mt-3" aria-hidden="true">${Array(rows).fill('<div class="mb-3"><span class="placeholder col-8"></span><span class="placeholder col-5 d-block mt-2"></span></div>').join("")}</div></div>`}
|
||||||
function empty(title,description,action=""){return `<div class="p-4"><h3 class="mb-2">${esc(title)}</h3><p class="text-secondary mb-0">${esc(description)}</p>${action?`<div class="mt-3">${action}</div>`:""}</div>`}
|
function empty(title,description,action=""){return `<div class="p-4"><h3 class="mb-2">${esc(title)}</h3><p class="text-secondary mb-0">${esc(description)}</p>${action?`<div class="mt-3">${action}</div>`:""}</div>`}
|
||||||
const BUSY=new Set();
|
const BUSY=new Set();
|
||||||
@@ -458,6 +462,7 @@ function paintNav(){
|
|||||||
const systemOpen=$("#system-nav")?.open||["overview","runs","keys"].includes(S.view);
|
const systemOpen=$("#system-nav")?.open||["overview","runs","keys"].includes(S.view);
|
||||||
const link=id=>{const n=NAV.find(v=>v.id===id);return `<li class="nav-item"><a class="nav-link ${S.view===id?"active":""}" href="#/${id}" data-nav="${id}" ${S.view===id?'aria-current="page"':""}><span class="nav-link-icon d-md-none d-lg-inline-block"><svg class="icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${n.icon}"/></svg></span><span class="nav-link-title">${n.label}</span></a></li>`};
|
const link=id=>{const n=NAV.find(v=>v.id===id);return `<li class="nav-item"><a class="nav-link ${S.view===id?"active":""}" href="#/${id}" data-nav="${id}" ${S.view===id?'aria-current="page"':""}><span class="nav-link-icon d-md-none d-lg-inline-block"><svg class="icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${n.icon}"/></svg></span><span class="nav-link-title">${n.label}</span></a></li>`};
|
||||||
$("#nav").innerHTML=`<div class="subheader px-3 mt-3 mb-2">Рабочая область</div><ul class="navbar-nav">${["feed","abuse","sources","tg"].map(link).join("")}</ul><div class="subheader px-3 mt-4 mb-2">Инструменты</div><ul class="navbar-nav">${["watch","digest"].map(link).join("")}</ul><details id="system-nav" class="mt-4 mb-3" ${systemOpen?"open":""}><summary class="subheader px-3">Система</summary><ul class="navbar-nav mt-2">${["overview","runs","keys"].map(link).join("")}</ul></details>`;
|
$("#nav").innerHTML=`<div class="subheader px-3 mt-3 mb-2">Рабочая область</div><ul class="navbar-nav">${["feed","abuse","sources","tg"].map(link).join("")}</ul><div class="subheader px-3 mt-4 mb-2">Инструменты</div><ul class="navbar-nav">${["watch","digest"].map(link).join("")}</ul><details id="system-nav" class="mt-4 mb-3" ${systemOpen?"open":""}><summary class="subheader px-3">Система</summary><ul class="navbar-nav mt-2">${["overview","runs","keys"].map(link).join("")}</ul></details>`;
|
||||||
|
const rail=$("#nav");if(rail.scrollHeight>rail.clientHeight)rail.querySelector(".nav-link.active")?.scrollIntoView({block:"nearest"});
|
||||||
}
|
}
|
||||||
function pageHeader(){const n=NAV.find(n=>n.id===S.view);document.title="OMNI — "+n.label;$("#crumb").textContent=n.label;$("#page-group").textContent=n.group;$("#page-description").textContent=n.description;
|
function pageHeader(){const n=NAV.find(n=>n.id===S.view);document.title="OMNI — "+n.label;$("#crumb").textContent=n.label;$("#page-group").textContent=n.group;$("#page-description").textContent=n.description;
|
||||||
const primary={feed:'<button class="btn btn-primary" data-act="run" data-lock="feed" type="button">Собрать записи</button>',sources:'<button class="btn btn-primary" data-act="add-src" type="button">Добавить источник</button>',abuse:S.tab==="signals"?'<button class="btn btn-primary" data-act="summary" data-lock="summary" type="button">Сводка по находкам</button>':"",digest:'<button class="btn btn-primary" data-act="digest" data-lock="digest" type="button">Собрать дайджест</button>',overview:'<a class="btn btn-primary" href="#/feed" data-nav="feed">Открыть ленту</a>'};
|
const primary={feed:'<button class="btn btn-primary" data-act="run" data-lock="feed" type="button">Собрать записи</button>',sources:'<button class="btn btn-primary" data-act="add-src" type="button">Добавить источник</button>',abuse:S.tab==="signals"?'<button class="btn btn-primary" data-act="summary" data-lock="summary" type="button">Сводка по находкам</button>':"",digest:'<button class="btn btn-primary" data-act="digest" data-lock="digest" type="button">Собрать дайджест</button>',overview:'<a class="btn btn-primary" href="#/feed" data-nav="feed">Открыть ленту</a>'};
|
||||||
@@ -548,7 +553,7 @@ async function startJob(path,body,key,el,label,after){return busy(el,key,async()
|
|||||||
function showModal(title,body,footer){modalReturnFocus=document.activeElement;$("#modal-box").innerHTML=`<div class="modal-header"><h2 class="modal-title" id="modal-title">${esc(title)}</h2><button class="btn btn-ghost-secondary btn-sm" type="button" data-bs-dismiss="modal" aria-label="Закрыть диалог">Закрыть</button></div><div class="modal-body">${body}</div><div class="modal-footer">${footer}</div>`;tabler.Modal.getOrCreateInstance($("#modal")).show()}
|
function showModal(title,body,footer){modalReturnFocus=document.activeElement;$("#modal-box").innerHTML=`<div class="modal-header"><h2 class="modal-title" id="modal-title">${esc(title)}</h2><button class="btn btn-ghost-secondary btn-sm" type="button" data-bs-dismiss="modal" aria-label="Закрыть диалог">Закрыть</button></div><div class="modal-body">${body}</div><div class="modal-footer">${footer}</div>`;tabler.Modal.getOrCreateInstance($("#modal")).show()}
|
||||||
$("#modal").addEventListener("hidden.bs.modal",()=>{if(modalReturnFocus?.isConnected)modalReturnFocus.focus({preventScroll:true});modalReturnFocus=null});
|
$("#modal").addEventListener("hidden.bs.modal",()=>{if(modalReturnFocus?.isConnected)modalReturnFocus.focus({preventScroll:true});modalReturnFocus=null});
|
||||||
function addSource(){showModal("Новый источник",`<form id="add-source-form" class="row g-3"><div class="col-sm-6"><label class="form-label" for="a-name">Название</label><input class="form-control" id="a-name" placeholder="example-forum" required></div><div class="col-sm-6"><label class="form-label" for="a-cat">Категория</label><input class="form-control" id="a-cat" value="community" list="cats-list" required><datalist id="cats-list">${S.cats.map(c=>`<option value="${esc(c)}">`).join("")}</datalist></div><div class="col-12"><label class="form-label" for="a-url">Адрес источника</label><input class="form-control" id="a-url" type="url" placeholder="https://example.com/rss" required></div><div class="col-sm-8"><label class="form-label" for="a-adapter">Адаптер</label><select class="form-select" id="a-adapter">${["rss","html_listing","json","tg_web","xenforo_latest","discourse","sitemap_recent"].map(a=>`<option>${a}</option>`).join("")}</select></div><div class="col-sm-4"><label class="form-label" for="a-lim">Лимит записей</label><input class="form-control" type="number" id="a-lim" min="1" max="500" value="20" required></div><div class="col-12"><label class="form-label" for="a-tags">Метки через запятую</label><input class="form-control" id="a-tags" placeholder="Тема, язык, проект"></div><div class="col-12" id="add-source-error" aria-live="polite"></div></form>`,'<button class="btn" type="button" data-bs-dismiss="modal">Отмена</button><button class="btn btn-primary" type="submit" form="add-source-form" data-lock="source-write">Добавить источник</button>')}
|
function addSource(){showModal("Новый источник",`<form id="add-source-form" class="row g-3"><div class="col-sm-6"><label class="form-label" for="a-name">Название</label><input class="form-control" id="a-name" placeholder="example-forum" required></div><div class="col-sm-6"><label class="form-label" for="a-cat">Категория</label><input class="form-control" id="a-cat" value="community" list="cats-list" required><datalist id="cats-list">${S.cats.map(c=>`<option value="${esc(c)}">`).join("")}</datalist></div><div class="col-12"><label class="form-label" for="a-url">Адрес источника</label><input class="form-control" id="a-url" type="url" placeholder="https://example.com/rss" required></div><div class="col-sm-8"><label class="form-label" for="a-adapter">Адаптер</label><select class="form-select" id="a-adapter">${["rss","html_listing","json","tg_web","xenforo_latest","discourse","sitemap_recent"].map(a=>`<option>${a}</option>`).join("")}</select></div><div class="col-sm-4"><label class="form-label" for="a-lim">Лимит записей</label><input class="form-control" type="number" id="a-lim" min="1" max="500" value="20" required></div><div class="col-12"><label class="form-label" for="a-tags">Метки через запятую</label><input class="form-control" id="a-tags" placeholder="Тема, язык, проект"></div><div class="col-12" id="add-source-error" aria-live="polite"></div></form>`,'<button class="btn" type="button" data-bs-dismiss="modal">Отмена</button><button class="btn btn-primary" type="submit" form="add-source-form" data-lock="source-write">Добавить источник</button>')}
|
||||||
async function saveSource(){const origin=activeContext,body={name:$("#a-name").value.trim(),category:$("#a-cat").value.trim()||"community",url:$("#a-url").value.trim(),adapter:$("#a-adapter").value,limit:int($("#a-lim").value,1,500),tags:$("#a-tags").value.split(",").map(t=>t.trim()).filter(Boolean)};if(!safeUrl(body.url)){$("#add-source-error").innerHTML=failure(new Error("Нужен адрес с протоколом https:// или http://"));return}try{await post("/api/sources/add",body);tabler.Modal.getOrCreateInstance($("#modal")).hide();toast("Источник добавлен");if(origin.valid())await render()}catch(error){if($("#add-source-error"))$("#add-source-error").innerHTML=failure(error)}}
|
async function saveSource(){const origin=activeContext,body={name:$("#a-name").value.trim(),category:$("#a-cat").value.trim()||"community",url:$("#a-url").value.trim(),adapter:$("#a-adapter").value,limit:int($("#a-lim").value,1,500),tags:$("#a-tags").value.split(",").map(t=>t.trim()).filter(Boolean)};if(!safeUrl(body.url)){$("#add-source-error").innerHTML=failure(new Error("Нужен адрес с протоколом https:// или http://"),null,"Проверьте адрес источника");return}try{await post("/api/sources/add",body);tabler.Modal.getOrCreateInstance($("#modal")).hide();toast("Источник добавлен");if(origin.valid())await render()}catch(error){if($("#add-source-error"))$("#add-source-error").innerHTML=failure(error)}}
|
||||||
async function bulkToggle(on,el){const names=selectedSrc();if(!names.length){toast("Выберите источники",true);return}await busy(el,"source-write",async()=>{const ctx=activeContext;for(const name of names)await post("/api/sources/toggle",{name,enabled:on});toast(`${on?"Включено":"Выключено"}: ${names.length}`);if(ctx.valid())await loadSources(ctx)})}
|
async function bulkToggle(on,el){const names=selectedSrc();if(!names.length){toast("Выберите источники",true);return}await busy(el,"source-write",async()=>{const ctx=activeContext;for(const name of names)await post("/api/sources/toggle",{name,enabled:on});toast(`${on?"Включено":"Выключено"}: ${names.length}`);if(ctx.valid())await loadSources(ctx)})}
|
||||||
async function toggleSource(name,on,el,detail=false){await busy(el,"source-write",async()=>{const ctx=activeContext,reader=readerVersion;try{await post("/api/sources/toggle",{name,enabled:on});toast(on?"Источник включён":"Источник выключен");if(ctx.valid()&&S.view==="sources")await loadSources(ctx);if(detail&&reader===readerVersion&&$("#drawer").classList.contains("show"))await srcDetail(name)}catch(error){if(el?.type==="checkbox")el.checked=!on;throw error}})}
|
async function toggleSource(name,on,el,detail=false){await busy(el,"source-write",async()=>{const ctx=activeContext,reader=readerVersion;try{await post("/api/sources/toggle",{name,enabled:on});toast(on?"Источник включён":"Источник выключен");if(ctx.valid()&&S.view==="sources")await loadSources(ctx);if(detail&&reader===readerVersion&&$("#drawer").classList.contains("show"))await srcDetail(name)}catch(error){if(el?.type==="checkbox")el.checked=!on;throw error}})}
|
||||||
async function addWatch(q,type="all",el){if(q.trim().length<2){toast("Введите не менее двух символов",true);return}await busy(el,"watch-write",async()=>{const ctx=activeContext;await post("/api/watch/add",{q:q.trim(),type});toast("Правило отслеживания добавлено");if(ctx.valid()&&S.view==="watch")await render()})}
|
async function addWatch(q,type="all",el){if(q.trim().length<2){toast("Введите не менее двух символов",true);return}await busy(el,"watch-write",async()=>{const ctx=activeContext;await post("/api/watch/add",{q:q.trim(),type});toast("Правило отслеживания добавлено");if(ctx.valid()&&S.view==="watch")await render()})}
|
||||||
|
|||||||
Reference in New Issue
Block a user