Files
caiwuzongzhang/tests/test_hel351_controls.py
T
acc3c29c49 HEL-351: 修复夜间下拉/日期弹层、一次性初始密码领取与缩放滚动条
夜间控件改走现有 token 与 color-scheme,日期弹层锚定触发器并可翻转;
创建公司账号改为一次性可复制口令窗口,列表与后续接口不再回明文。

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

272 lines
12 KiB
Python

"""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"""<!doctype html>
<html lang="zh-CN" class="v-fusion" {theme_attr}>
<head>
<meta charset="UTF-8"/>
<link rel="stylesheet" href="{self.base}/design-system.css"/>
</head>
<body class="is-app" {theme_attr}>
<div class="tabs" id="probe-tabs">
<button class="active">全部</button>
<button>流水断档</button>
<button>单边匹配</button>
<button>科目确认</button>
<button>起算区间校准</button>
<button>银行账户登记</button>
<button>公司手工记录</button>
<button>重开审批</button>
</div>
<select class="select" id="probe-select">
<option>河南金牛农业科技发展有限公司</option>
<option>河南金牛煤业有限公司</option>
</select>
<input class="input" id="probe-date" type="date" value="2026-09-01" />
<script src="{self.base}/datepicker.js"></script>
</body></html>""",
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"""<!doctype html>
<html lang="zh-CN" class="v-fusion" data-theme="night">
<head>
<meta charset="UTF-8"/>
<link rel="stylesheet" href="{self.base}/design-system.css"/>
</head>
<body class="is-app" data-theme="night" style="min-height:100vh">
<input class="input" id="probe-date" type="date" value="2026-09-01" style="position:fixed;left:24px;bottom:12px;width:180px" />
<script src="{self.base}/datepicker.js"></script>
</body></html>""",
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()