HEL-351: 修复夜间下拉/日期弹层、一次性初始密码领取与缩放滚动条
夜间控件改走现有 token 与 color-scheme,日期弹层锚定触发器并可翻转; 创建公司账号改为一次性可复制口令窗口,列表与后续接口不再回明文。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Cursor
multica-agent
parent
9d06445d81
commit
acc3c29c49
@@ -0,0 +1,271 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user