Files
caiwuzongzhang/tests/test_hel351_controls.py
T
6eccbe9765 HEL-373: 主题化 combobox 覆盖夜间下拉全状态,日期面板支持年月直选
原生 select 弹层无法可靠套黑金 token,改为可访问 listbox;日期标题增加年份/月份下拉,越月不提交非法日。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-02 11:07:16 +08:00

295 lines
13 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("select.js?v=1", html)
self.assertIn("datepicker.js?v=2", 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 value="">请选择公司</option>
<option>河南金牛农业科技发展有限公司</option>
<option>河南金牛煤业有限公司</option>
<option disabled>停用账户(不可选)</option>
</select>
<input class="input" id="probe-date" type="date" value="2026-09-01" />
<script src="{self.base}/select.js"></script>
<script src="{self.base}/datepicker.js"></script>
</body></html>""",
wait_until="domcontentloaded",
)
page.wait_for_function("() => window.JinniuDatePicker && window.JinniuSelect")
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)
page.locator("#probe-select").locator("xpath=ancestor::div[contains(@class,'ds-combo')]").locator(".ds-combo-trigger").click()
page.wait_for_selector("#ds-listbox:not([hidden])")
colors = page.evaluate(
"""() => {
const trigger = document.querySelector('#probe-select').closest('.ds-combo').querySelector('.ds-combo-trigger');
const box = document.getElementById('ds-listbox');
const placeholder = box.querySelector('.is-placeholder');
const plain = [...box.querySelectorAll('.ds-listbox-option')].find((el) => !el.classList.contains('is-placeholder') && !el.classList.contains('is-disabled') && !el.classList.contains('is-selected'));
const disabled = box.querySelector('.is-disabled');
const tcs = getComputedStyle(trigger);
const bcs = getComputedStyle(box);
const pcs = placeholder && getComputedStyle(placeholder);
const ncs = plain && getComputedStyle(plain);
const dcs = disabled && getComputedStyle(disabled);
return {
triggerBg: tcs.backgroundColor,
triggerColor: tcs.color,
boxBg: bcs.backgroundColor,
boxColor: bcs.color,
placeholderColor: pcs && pcs.color,
placeholderBg: pcs && pcs.backgroundColor,
plainColor: ncs && ncs.color,
plainBg: ncs && ncs.backgroundColor,
disabledColor: dcs && dcs.color,
};
}"""
)
browser.close()
white = "rgb(255, 255, 255)"
self.assertNotEqual(white, colors["triggerBg"])
self.assertNotEqual(white, colors["triggerColor"])
self.assertNotEqual(white, colors["boxBg"])
self.assertNotEqual(white, colors["boxColor"])
self.assertNotEqual(white, colors["placeholderBg"])
self.assertNotEqual(white, colors["placeholderColor"])
self.assertNotEqual(white, colors["plainBg"])
self.assertNotEqual(white, colors["plainColor"])
self.assertNotEqual(white, colors["disabledColor"])
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()