"""HEL-351: 夜间控件、一次性初始密码交付、缩放滚动条契约。""" from __future__ import annotations import os import threading import unittest from functools import partial from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path ROOT = Path(__file__).resolve().parents[1] WEB = ROOT / "web" 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 class Hel351SourceContractTests(unittest.TestCase): def test_night_select_and_scrollbar_use_tokens(self) -> None: css = (WEB / "design-system.css").read_text(encoding="utf-8") self.assertIn("color-scheme: light", css) self.assertIn("color-scheme: dark", css) self.assertIn("scrollbar-color: var(--gold) var(--surface-2)", css) self.assertIn("html[data-theme=\"night\"] ::-webkit-scrollbar-thumb", css) self.assertIn("background-color: var(--gold)", css) self.assertIn(".select option", css) self.assertIn("background-color: var(--surface)", css) self.assertIn("html[data-theme=\"night\"] .ds-dp-day.is-selected", css) self.assertIn("color: var(--gold)", css) self.assertNotIn("overflow: hidden; /* HEL-351", css) def test_tabs_and_table_split_overflow_axes(self) -> None: css = (WEB / "design-system.css").read_text(encoding="utf-8") tabs = css[css.index(".tabs {") : css.index(".tabs button")] self.assertIn("overflow-x: auto", tabs) self.assertIn("overflow-y: hidden", tabs) wrap = css[css.index(".table-wrap {") : css.index(".table-wrap.dash-master-scroll")] self.assertIn("overflow-x: auto", wrap) self.assertIn("overflow-y: hidden", wrap) self.assertIn(".table-wrap.dash-master-scroll", css) self.assertIn("overflow-y: auto", css[css.index(".table-wrap.dash-master-scroll") :][:180]) def test_admin_delivers_once_password_window(self) -> None: html = (WEB / "admin.html").read_text(encoding="utf-8") self.assertIn('id="credentialDialog"', html) self.assertIn('id="cred-pass"', html) self.assertIn('id="cred-copy-pass"', html) self.assertIn("datepicker.js?v=1", html) self.assertNotIn("初始密码由管理员统一发放", html) js = (WEB / "app.js").read_text(encoding="utf-8") self.assertIn("function showOnceCredentials(", js) self.assertIn("function wipeCredentials(", js) self.assertNotIn("初始密码已生成(仅此一次显示):${result.initial_password}", js) self.assertNotIn("临时密码已生成(仅此一次):${result.initial_password}", js) self.assertIn('openModal("credentialDialog")', js) def test_datepicker_follows_trigger_and_flips(self) -> None: js = (WEB / "datepicker.js").read_text(encoding="utf-8") css = (WEB / "design-system.css").read_text(encoding="utf-8") self.assertIn("visualViewport", js) self.assertIn("getBoundingClientRect", js) self.assertIn("rect.top - gap - height", js) self.assertIn('addEventListener("scroll", position, true)', js) block = css[css.index(".ds-datepicker {") : css.index(".ds-datepicker[hidden]")] self.assertIn("position: fixed", block) self.assertIn("z-index: var(--z-tooltip)", block) @unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器探针") @unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器探针") class Hel351BrowserProbeTests(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 = 1440, zoom: float = 1) -> None: page.set_viewport_size({"width": width, "height": 900}) theme_attr = 'data-theme="night"' if night else 'data-theme="day"' page.set_content( f"""
""", wait_until="domcontentloaded", ) page.wait_for_function("() => window.JinniuDatePicker") if zoom != 1: page.evaluate(f"() => {{ document.body.style.zoom = '{zoom}'; }}") def test_night_select_is_not_white_on_white(self) -> None: with sync_playwright() as p: browser = p.chromium.launch(headless=True, args=["--no-sandbox"]) page = browser.new_page(viewport={"width": 1440, "height": 900}) self._open(page, night=True) colors = page.evaluate( """() => { const sel = document.getElementById('probe-select'); const cs = getComputedStyle(sel); const opt = getComputedStyle(sel.options[0]); return { scheme: getComputedStyle(document.documentElement).colorScheme, bg: cs.backgroundColor, color: cs.color, optionBg: opt.backgroundColor, optionColor: opt.color, }; }""" ) browser.close() self.assertEqual("dark", colors["scheme"]) self.assertNotEqual("rgb(255, 255, 255)", colors["bg"]) self.assertNotEqual("rgb(255, 255, 255)", colors["color"]) self.assertNotEqual("rgb(255, 255, 255)", colors["optionBg"]) def test_tabs_have_no_vertical_scrollbar_at_zoom(self) -> None: with sync_playwright() as p: browser = p.chromium.launch(headless=True, args=["--no-sandbox"]) page = browser.new_page(viewport={"width": 390, "height": 800}) self._open(page, night=False, width=390, zoom=1.25) metrics = page.evaluate( """() => { const tabs = document.getElementById('probe-tabs'); const cs = getComputedStyle(tabs); return { overflowX: cs.overflowX, overflowY: cs.overflowY, clientHeight: tabs.clientHeight, scrollHeight: tabs.scrollHeight, clientWidth: tabs.clientWidth, scrollWidth: tabs.scrollWidth, }; }""" ) browser.close() self.assertEqual("auto", metrics["overflowX"]) self.assertEqual("hidden", metrics["overflowY"]) self.assertLessEqual(metrics["scrollHeight"] - metrics["clientHeight"], 1) def test_datepicker_anchors_and_night_gold(self) -> None: with sync_playwright() as p: browser = p.chromium.launch(headless=True, args=["--no-sandbox"]) page = browser.new_page(viewport={"width": 1440, "height": 900}) self._open(page, night=True) page.click("#probe-date") page.wait_for_selector("#ds-datepicker:not([hidden])") box = page.evaluate( """() => { const input = document.getElementById('probe-date'); const panel = document.getElementById('ds-datepicker'); const ir = input.getBoundingClientRect(); const pr = panel.getBoundingClientRect(); const selected = panel.querySelector('.ds-dp-day.is-selected'); const cs = selected ? getComputedStyle(selected) : null; return { inputBottom: ir.bottom, inputLeft: ir.left, panelTop: pr.top, panelLeft: pr.left, panelRight: pr.right, viewportWidth: window.innerWidth, selectedColor: cs && cs.color, selectedBg: cs && cs.backgroundColor, hidden: panel.hidden, }; }""" ) browser.close() self.assertFalse(box["hidden"]) self.assertLess(abs(box["panelTop"] - box["inputBottom"]), 24) self.assertLess(abs(box["panelLeft"] - box["inputLeft"]), 24) self.assertLess(box["panelRight"], box["viewportWidth"]) self.assertIsNotNone(box["selectedColor"]) self.assertNotEqual("rgb(255, 255, 255)", box["selectedBg"]) self.assertIn("217", box["selectedColor"]) def test_datepicker_flips_when_near_bottom(self) -> None: with sync_playwright() as p: browser = p.chromium.launch(headless=True, args=["--no-sandbox"]) page = browser.new_page(viewport={"width": 1440, "height": 500}) page.set_content( f""" """, wait_until="domcontentloaded", ) page.wait_for_function("() => window.JinniuDatePicker") page.click("#probe-date") page.wait_for_selector("#ds-datepicker:not([hidden])") box = page.evaluate( """() => { const input = document.getElementById('probe-date'); const panel = document.getElementById('ds-datepicker'); const ir = input.getBoundingClientRect(); const pr = panel.getBoundingClientRect(); return { inputTop: ir.top, panelBottom: pr.bottom, panelTop: pr.top }; }""" ) browser.close() self.assertLess(box["panelBottom"], box["inputTop"] + 2) self.assertGreater(box["inputTop"] - box["panelBottom"], 0) if __name__ == "__main__": unittest.main()