From 1061f125a848897a9ff23bd39407e7776c936168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Tue, 1 Sep 2026 22:53:58 +0800 Subject: [PATCH] =?UTF-8?q?HEL-360:=20=E6=89=8B=E6=9C=BA=E7=AB=AF=20Toast?= =?UTF-8?q?=20=E8=A6=86=E7=9B=96=E5=B1=82=E6=94=B9=E4=B8=BA=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E7=A9=BF=E9=80=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ≤720px 时 toast-region 因 top/left/right/bottom 铺满视口,空白处不再拦截导航和表单;Toast 本体仍可点。 Co-authored-by: Cursor Co-authored-by: multica-agent --- tests/test_hel360_toast_pointer.py | 411 +++++++++++++++++++++++++++++ web/design-system.css | 3 +- 2 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 tests/test_hel360_toast_pointer.py diff --git a/tests/test_hel360_toast_pointer.py b/tests/test_hel360_toast_pointer.py new file mode 100644 index 0000000..063d8ae --- /dev/null +++ b/tests/test_hel360_toast_pointer.py @@ -0,0 +1,411 @@ +"""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""" + + + + + + + + + 往来查询 +
+ +
+ +
+
+ +
+
提示
+ + +
+
+
+ +""", + 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() diff --git a/web/design-system.css b/web/design-system.css index 8b21497..3ea3295 100644 --- a/web/design-system.css +++ b/web/design-system.css @@ -1280,7 +1280,7 @@ a.flow-step.doing:hover { background: color-mix(in oklch, var(--warn) 16%, trans } /* ─── Toast 轻量提示 ───────────────────────────────────────────── */ -.toast-region { position: fixed; right: 16px; top: 16px; z-index: var(--z-toast); display: grid; gap: 8px; } +.toast-region { position: fixed; right: 16px; top: 16px; z-index: var(--z-toast); display: grid; gap: 8px; pointer-events: none; } .toast { display: flex; align-items: center; gap: 9px; min-width: 260px; max-width: 380px; @@ -1292,6 +1292,7 @@ a.flow-step.doing:hover { background: color-mix(in oklch, var(--warn) 16%, trans color: #fff; box-shadow: var(--shadow-toast); font-size: 13px; + pointer-events: auto; } .toast .t-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; margin-top: 5px; background: var(--muted); } .toast .t-body { min-width: 0; }