Files
caiwuzongzhang/tests/test_hel360_toast_pointer.py
T
1061f125a8 HEL-360: 手机端 Toast 覆盖层改为点击穿透
≤720px 时 toast-region 因 top/left/right/bottom 铺满视口,空白处不再拦截导航和表单;Toast 本体仍可点。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-01 22:53:58 +08:00

412 lines
17 KiB
Python

"""HEL-360: 手机端 Toast 覆盖层不得拦截页面点击。"""
from __future__ import annotations
import os
import re
import tempfile
import threading
import unittest
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from bank_importer import auth, master_data
from bank_importer.db import connect, migrate
import server
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_PASSWORD = "CashierA123"
try:
from playwright.sync_api import sync_playwright
except ImportError: # pragma: no cover
sync_playwright = None
def _prepare_chrome_libs() -> Path | None:
candidates = [ROOT / ".chrome-libs" / "lib"]
for lib_dir in candidates:
if (lib_dir / "libatk-1.0.so.0").exists():
current = os.environ.get("LD_LIBRARY_PATH", "")
prefix = str(lib_dir)
if prefix not in current.split(":"):
os.environ["LD_LIBRARY_PATH"] = (
f"{prefix}:{current}" if current else prefix
)
return lib_dir
return None
def _chromium_available() -> bool:
if not sync_playwright:
return False
_prepare_chrome_libs()
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
browser.close()
return True
except Exception:
return False
def _css_rule(css: str, selector: str) -> str:
match = re.search(rf"{re.escape(selector)} \{{([^}}]*)\}}", css)
if not match:
raise AssertionError(f"missing rule {selector}")
return match.group(1)
def _mouse_click_center(page, selector: str) -> tuple[float, float]:
loc = page.locator(selector).first
loc.wait_for(state="visible", timeout=8000)
loc.scroll_into_view_if_needed()
page.wait_for_function(
"""(sel) => {
const el = document.querySelector(sel);
if (!el) return false;
const r = el.getBoundingClientRect();
return r.width > 2 && r.height > 2 && r.right > 0 && r.bottom > 0
&& r.left < window.innerWidth && r.top < window.innerHeight;
}""",
arg=selector,
timeout=4000,
)
box = loc.bounding_box()
assert box, f"{selector} has no box"
x = box["x"] + box["width"] / 2
y = box["y"] + box["height"] / 2
page.mouse.click(x, y)
return x, y
def _hit(page, x: float, y: float) -> dict:
return page.evaluate(
"""({x, y}) => {
const el = document.elementFromPoint(x, y);
const region = document.getElementById('toastRegion');
const toast = el && el.closest ? el.closest('.toast') : null;
return {
tag: el ? el.tagName : null,
id: el ? el.id : null,
className: el && el.className ? String(el.className) : '',
inRegion: !!(region && el && region.contains(el)),
inToast: !!toast,
regionPe: region ? getComputedStyle(region).pointerEvents : null,
toastPe: toast ? getComputedStyle(toast).pointerEvents : null,
};
}""",
{"x": x, "y": y},
)
class Hel360SourceContractTests(unittest.TestCase):
def test_toast_region_passes_clicks_to_toast_only(self) -> None:
css = (WEB / "design-system.css").read_text(encoding="utf-8")
region = _css_rule(css, ".toast-region")
toast = _css_rule(css, ".toast")
self.assertIn("pointer-events: none", region)
self.assertIn("pointer-events: auto", toast)
self.assertIn("z-index: var(--z-toast)", region)
mobile = css[css.index("@media (max-width: 720px) {\n .toast-region") :]
mobile_block = mobile[: mobile.index("\n}")]
self.assertIn("left: 12px", mobile_block)
self.assertIn("bottom: 12px", mobile_block)
self.assertIn("right: 12px", mobile_block)
self.assertNotIn("pointer-events: none !important", css)
self.assertNotIn("z-index: -1", region)
self.assertNotIn("z-index: 0", region)
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器探针")
@unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器探针")
class Hel360CssPointerProbeTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
handler = partial(SimpleHTTPRequestHandler, directory=str(WEB))
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.base = f"http://127.0.0.1:{cls.port}"
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
def _open(self, page, *, night: bool, width: int, height: int = 640) -> None:
page.set_viewport_size({"width": width, "height": height})
theme = "night" if night else "day"
page.set_content(
f"""<!doctype html>
<html lang="zh-CN" class="v-fusion" data-theme="{theme}">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" href="{self.base}/design-system.css"/>
</head>
<body class="is-app" data-theme="{theme}" style="margin:0;min-height:100vh">
<button type="button" id="page-btn" style="position:fixed;left:50%;top:58%;transform:translate(-50%,-50%);z-index:1">页面按钮</button>
<a id="page-nav" href="#pair" style="position:fixed;left:16px;top:88px;z-index:1">往来查询</a>
<div class="tabs" style="position:fixed;left:16px;top:124px;z-index:1;width:220px">
<button type="button" id="page-tab">页签</button>
</div>
<input class="input" id="page-date" type="date" value="2026-09-01" style="position:fixed;left:16px;top:176px;z-index:1;width:160px" />
<div class="toast-region" id="toastRegion" aria-live="polite">
<div class="toast info" id="the-toast" role="status">
<span class="t-dot"></span>
<div class="t-body">
<div class="t-title">提示</div>
<button type="button" id="toast-copy">复制</button>
<button type="button" id="toast-close">关闭</button>
</div>
</div>
</div>
<script>
window.__clicks = [];
["page-btn","page-nav","page-tab","page-date","toast-copy","toast-close"].forEach((id) => {{
document.getElementById(id).addEventListener("click", () => window.__clicks.push(id));
}});
</script>
</body></html>""",
wait_until="domcontentloaded",
)
page.wait_for_selector("#toastRegion")
def test_empty_overlay_does_not_eat_real_clicks(self) -> None:
cases = [
(360, False, 640),
(390, True, 640),
(820, False, 900),
(1440, True, 900),
]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
page = browser.new_page()
try:
for width, night, height in cases:
with self.subTest(width=width, night=night):
self._open(page, night=night, width=width, height=height)
metrics = page.evaluate(
"""() => {
const tr = document.getElementById('toastRegion');
const toast = document.getElementById('the-toast');
const r = tr.getBoundingClientRect();
return {
regionPe: getComputedStyle(tr).pointerEvents,
toastPe: getComputedStyle(toast).pointerEvents,
height: r.height,
width: r.width,
top: r.top,
viewportH: window.innerHeight,
};
}"""
)
self.assertEqual("none", metrics["regionPe"], metrics)
self.assertEqual("auto", metrics["toastPe"], metrics)
if width <= 720:
self.assertGreater(metrics["height"], metrics["viewportH"] - 40, metrics)
page.evaluate("() => { window.__clicks = []; }")
_mouse_click_center(page, "#page-btn")
_mouse_click_center(page, "#page-nav")
_mouse_click_center(page, "#page-tab")
_mouse_click_center(page, "#page-date")
clicks = page.evaluate("() => window.__clicks.slice()")
self.assertEqual(
["page-btn", "page-nav", "page-tab", "page-date"],
clicks,
f"width={width} night={night} clicks={clicks}",
)
cx, cy = _mouse_click_center(page, "#toast-copy")
hit = _hit(page, cx, cy)
self.assertTrue(hit["inToast"], hit)
_mouse_click_center(page, "#toast-close")
toast_clicks = page.evaluate("() => window.__clicks.slice(-2)")
self.assertEqual(["toast-copy", "toast-close"], toast_clicks)
mid_x = width / 2
mid_y = height * 0.58
mid = _hit(page, mid_x, mid_y)
self.assertFalse(mid["inRegion"] and not mid["inToast"], mid)
self.assertEqual("none", mid["regionPe"], mid)
finally:
browser.close()
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器冒烟")
@unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器冒烟")
class Hel360LiveAppClickTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
_prepare_chrome_libs()
cls.temp_dir = tempfile.TemporaryDirectory()
root = Path(cls.temp_dir.name)
cls.db_path = root / "app.db"
cls.storage = root / "files"
cls.storage.mkdir()
cls._old_db = server.DB_PATH
cls._old_storage = server.STORAGE_DIR
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage
connection = connect(cls.db_path)
migrate(connection)
auth.create_user(
connection, "group-admin", ADMIN_PASSWORD, "admin", must_change_password=False
)
company_id = master_data.create_company(connection, "甲公司", None, None, None)
auth.create_user(
connection,
"cashier-a",
CASHIER_PASSWORD,
"company",
company_id,
must_change_password=False,
)
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None:
pass
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.base = f"http://127.0.0.1:{cls.port}"
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
server.DB_PATH = cls._old_db
server.STORAGE_DIR = cls._old_storage
cls.temp_dir.cleanup()
def _login(self, page, *, portal: str) -> None:
login_path = "login-admin.html" if portal == "admin" else "login-company.html"
username = "group-admin" if portal == "admin" else "cashier-a"
password = ADMIN_PASSWORD if portal == "admin" else CASHIER_PASSWORD
page.goto(f"{self.base}/{login_path}", wait_until="domcontentloaded")
page.fill("#account", username)
page.fill("#password", password)
page.locator('button[type="submit"]').click()
expect = "admin.html" if portal == "admin" else "company.html"
page.wait_for_url(f"**/{expect}", timeout=15000)
page.wait_for_selector(".side-nav", timeout=10000)
def _set_theme(self, page, night: bool) -> None:
theme = "night" if night else "day"
page.evaluate(
"""(theme) => {
document.documentElement.setAttribute('data-theme', theme);
document.body.setAttribute('data-theme', theme);
try { localStorage.setItem('jinniu-theme', theme); } catch (e) {}
}""",
theme,
)
def _goto_view(self, page, view: str) -> None:
_mouse_click_center(page, f'.side-nav a[data-view="{view}"]')
page.wait_for_selector(f'.app-view[data-page="{view}"].is-active', timeout=4000)
def _probe_toast_passthrough(self, page, width: int, height: int) -> None:
page.evaluate(
"""() => {
window.__pageHits = 0;
const btn = document.querySelector('.topbar .icon-button, .menu-button, .btn');
if (btn) btn.addEventListener('click', () => { window.__pageHits += 1; }, { once: true });
if (typeof showToast === 'function') showToast('HEL-360', '点击穿透', 'info');
}"""
)
page.wait_for_selector("#toastRegion .toast", timeout=4000)
toast_box = page.locator("#toastRegion .toast").first.bounding_box()
self.assertIsNotNone(toast_box)
page.mouse.click(
toast_box["x"] + toast_box["width"] / 2,
toast_box["y"] + toast_box["height"] / 2,
)
toast_hit = _hit(
page,
toast_box["x"] + toast_box["width"] / 2,
toast_box["y"] + toast_box["height"] / 2,
)
self.assertTrue(toast_hit["inToast"], toast_hit)
self.assertEqual("auto", toast_hit["toastPe"], toast_hit)
empty_x = width / 2
empty_y = height * 0.62
empty = _hit(page, empty_x, empty_y)
self.assertEqual("none", empty["regionPe"], empty)
self.assertFalse(empty["inToast"], empty)
self.assertFalse(empty["inRegion"], empty)
page.mouse.click(empty_x, empty_y)
after = _hit(page, empty_x, empty_y)
self.assertFalse(after["inRegion"], after)
def test_real_clicks_reach_admin_and_company_controls(self) -> None:
cases = [
("admin", 360, 640, False),
("admin", 390, 640, True),
("company", 360, 640, True),
("company", 390, 640, False),
("admin", 820, 900, False),
("admin", 1440, 900, True),
]
with sync_playwright() as p:
browser = p.chromium.launch(
headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"]
)
try:
for portal, width, height, night in cases:
with self.subTest(portal=portal, width=width, night=night):
context = browser.new_context(viewport={"width": 1440, "height": 900})
page = context.new_page()
try:
self._login(page, portal=portal)
self._set_theme(page, night)
page.set_viewport_size({"width": width, "height": height})
page.wait_for_timeout(200)
region = page.evaluate(
"""() => {
const tr = document.getElementById('toastRegion');
const cs = getComputedStyle(tr);
const r = tr.getBoundingClientRect();
return { pe: cs.pointerEvents, height: r.height, vw: window.innerWidth };
}"""
)
self.assertEqual("none", region["pe"], region)
if portal == "admin":
self._goto_view(page, "audit")
tab_sel = '#auditTabs button[data-audit-filter="断档"]'
_mouse_click_center(page, tab_sel)
self.assertEqual("true", page.locator(tab_sel).get_attribute("aria-pressed"))
self._goto_view(page, "pair")
date_sel = "#pairEnd"
else:
self._goto_view(page, "reconcile")
_mouse_click_center(page, "#tab-subject")
self.assertTrue(page.locator("#panel-subject").is_visible())
self._goto_view(page, "flows")
date_sel = "#flowStart"
page.locator(date_sel).scroll_into_view_if_needed()
_mouse_click_center(page, date_sel)
page.wait_for_selector("#ds-datepicker:not([hidden])", timeout=4000)
page.keyboard.press("Escape")
self._probe_toast_passthrough(page, width, height)
finally:
context.close()
finally:
browser.close()
if __name__ == "__main__":
unittest.main()