夜间控件改走现有 token 与 color-scheme,日期弹层锚定触发器并可翻转; 创建公司账号改为一次性可复制口令窗口,列表与后续接口不再回明文。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
272 lines
12 KiB
Python
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()
|