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; }