diff --git a/tests/test_company_confirm_status_color.py b/tests/test_company_confirm_status_color.py
index ec3c497..34c1f67 100644
--- a/tests/test_company_confirm_status_color.py
+++ b/tests/test_company_confirm_status_color.py
@@ -50,7 +50,7 @@ class ConfirmStatusSourceContractTests(unittest.TestCase):
self.assertIn('id="workspacePendingStatus"', html)
self.assertIn('id="workspaceFlowSub"', html)
self.assertIn('data-view-link="reconcile"', html)
- self.assertIn("app.js?v=17", html)
+ self.assertIn("app.js?v=18", html)
# 静态初值仍为进行中(黄),由 JS 在 pending=0 时切 done
self.assertRegex(html, r'class="flow-step doing"[^>]*data-view-link="reconcile"')
diff --git a/tests/test_company_transfers_page.py b/tests/test_company_transfers_page.py
index 2a0cf6b..dec2b1c 100644
--- a/tests/test_company_transfers_page.py
+++ b/tests/test_company_transfers_page.py
@@ -31,8 +31,8 @@ class TransfersPageSourceContractTests(unittest.TestCase):
self.assertIn('id="transferEvidenceDrawer"', html)
self.assertIn("期间净变动", html)
self.assertNotIn("本公司往来合计", html)
- self.assertIn("design-system.css?v=12", html)
- self.assertIn("app.js?v=17", html)
+ self.assertIn("design-system.css?v=13", html)
+ self.assertIn("app.js?v=18", html)
# 侧栏顺序:流水管理 → 转账往来 → 往来确认
flows = html.index('data-view="flows"')
transfers = html.index('data-view="transfers"')
diff --git a/tests/test_hel351_controls.py b/tests/test_hel351_controls.py
new file mode 100644
index 0000000..a4368ac
--- /dev/null
+++ b/tests/test_hel351_controls.py
@@ -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"""
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+""",
+ 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()
diff --git a/tests/test_reminders_page.py b/tests/test_reminders_page.py
index fd99ddb..a3d1cee 100644
--- a/tests/test_reminders_page.py
+++ b/tests/test_reminders_page.py
@@ -37,8 +37,8 @@ class RemindersPageSourceContractTests(unittest.TestCase):
self.assertIn('id="reminder-tbody"', html)
self.assertIn('id="reminder-tabs"', html)
self.assertIn('id="reminder-detail-drawer"', html)
- self.assertIn("design-system.css?v=12", html)
- self.assertIn("app.js?v=17", html)
+ self.assertIn("design-system.css?v=13", html)
+ self.assertIn("app.js?v=18", html)
pending = html.index('id="pending-reminders-card"')
history = html.index('id="reminder-history-card"')
send = html.index('id="send-reminder-card"')
@@ -135,7 +135,7 @@ class RemindersPageLayoutSmokeTests(unittest.TestCase):
def test_send_flow_columns_and_no_page_overflow(self) -> None:
html = (WEB / "admin.html").read_text(encoding="utf-8")
- self.assertIn("app.js?v=17", html)
+ self.assertIn("app.js?v=18", html)
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
diff --git a/tests/test_server_auth.py b/tests/test_server_auth.py
index 9478c0d..48e0242 100644
--- a/tests/test_server_auth.py
+++ b/tests/test_server_auth.py
@@ -540,6 +540,9 @@ class ServerAuthMatrixTests(unittest.TestCase):
status, _, data = self.admin.get("/api/admin/users")
self.assertEqual(200, status)
bodies.append(data)
+ status, _, data = self.admin.get("/api/admin/companies")
+ self.assertEqual(200, status)
+ bodies.append(data)
status, _, data = self.admin.get("/api/admin/audit-log?limit=100")
self.assertEqual(200, status)
bodies.append(data)
@@ -548,7 +551,9 @@ class ServerAuthMatrixTests(unittest.TestCase):
status, _, data = self.cashier_a.get("/api/batches")
bodies.append(data)
for body in bodies:
- self.assertNotIn("password_hash", body.decode("utf-8"))
+ text = body.decode("utf-8")
+ self.assertNotIn("password_hash", text)
+ self.assertNotIn("initial_password", text)
def test_audit_log_contains_no_plaintext_passwords(self) -> None:
connection = connect(self.db_path)
diff --git a/web/admin.html b/web/admin.html
index 115294a..d8068af 100644
--- a/web/admin.html
+++ b/web/admin.html
@@ -11,7 +11,7 @@
document.documentElement.classList.add("v-fusion");
})();
-
+
跳到主要内容
@@ -512,7 +512,7 @@
@@ -876,7 +876,7 @@
新增成员公司
- 登记后系统将生成公司端登录账号,初始密码由管理员统一发放。
+ 登记后可同时生成公司端登录账号。系统会发一次性随机初始密码,仅创建成功时显示一次,请当场复制后交给出纳;首次登录必须改密。没有全公司通用默认密码。