From acc3c29c4975f6a8094b364650d2adf01a5da2d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Tue, 1 Sep 2026 21:58:56 +0800 Subject: [PATCH] =?UTF-8?q?HEL-351:=20=E4=BF=AE=E5=A4=8D=E5=A4=9C=E9=97=B4?= =?UTF-8?q?=E4=B8=8B=E6=8B=89/=E6=97=A5=E6=9C=9F=E5=BC=B9=E5=B1=82?= =?UTF-8?q?=E3=80=81=E4=B8=80=E6=AC=A1=E6=80=A7=E5=88=9D=E5=A7=8B=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E9=A2=86=E5=8F=96=E4=B8=8E=E7=BC=A9=E6=94=BE=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 夜间控件改走现有 token 与 color-scheme,日期弹层锚定触发器并可翻转; 创建公司账号改为一次性可复制口令窗口,列表与后续接口不再回明文。 Co-authored-by: Cursor Co-authored-by: multica-agent --- tests/test_company_confirm_status_color.py | 2 +- tests/test_company_transfers_page.py | 4 +- tests/test_hel351_controls.py | 271 +++++++++++++++++++++ tests/test_reminders_page.py | 6 +- tests/test_server_auth.py | 7 +- web/admin.html | 38 ++- web/app.js | 91 ++++++- web/company.html | 5 +- web/datepicker.js | 268 ++++++++++++++++++++ web/design-system.css | 255 ++++++++++++++++++- web/index.html | 2 +- web/login-admin.html | 2 +- web/login-company.html | 2 +- 13 files changed, 917 insertions(+), 36 deletions(-) create mode 100644 tests/test_hel351_controls.py create mode 100644 web/datepicker.js 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 @@
共 0 家公司 - 登录账号初始密码由管理员统一发放 + 创建账号时生成一次性随机密码,仅当时显示一次
@@ -876,7 +876,7 @@ 新增成员公司 - +
@@ -899,7 +899,7 @@
-

创建后生成随机初始密码,仅显示一次,首次登录必须修改。

+

创建成功后弹出一次性口令窗口,可复制账号和密码。关闭后无法再次查看明文,列表与日志也不会保存密码。

+ + +