B-229: 登录页改为桌面双区布局并收紧三档响应式
把居中手机卡改回横向双区,端口选择复用分段控件节奏,避免再叠加一套登录样式。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
"""Static contracts for the B-229 login desktop layout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
WEB = ROOT / "web"
|
||||
|
||||
|
||||
class LoginFrontendContractTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.html = (WEB / "index.html").read_text(encoding="utf-8")
|
||||
cls.css = (WEB / "styles.css").read_text(encoding="utf-8")
|
||||
cls.js = (WEB / "app.js").read_text(encoding="utf-8")
|
||||
|
||||
def test_dual_zone_markup_and_auth_controls(self) -> None:
|
||||
self.assertIn('class="entry-context"', self.html)
|
||||
self.assertIn('class="entry-form-wrap"', self.html)
|
||||
self.assertIn('id="loginForm"', self.html)
|
||||
self.assertIn('id="togglePassword"', self.html)
|
||||
self.assertIn('id="changePassword"', self.html)
|
||||
self.assertIn('id="loginError"', self.html)
|
||||
self.assertIn('name="username"', self.html)
|
||||
self.assertIn('name="password"', self.html)
|
||||
self.assertIn('name="new_password"', self.html)
|
||||
self.assertIn('name="confirm_password"', self.html)
|
||||
self.assertIn('value="admin"', self.html)
|
||||
self.assertIn('value="company"', self.html)
|
||||
self.assertIn("总账管理端", self.html)
|
||||
self.assertIn("公司业务端", self.html)
|
||||
self.assertIn("服务状态", self.html)
|
||||
self.assertIn("当前账期", self.html)
|
||||
self.assertNotIn("entry-card", self.html)
|
||||
self.assertNotIn('value="group-admin"', self.html)
|
||||
self.assertNotIn('value="demo123456"', self.html)
|
||||
|
||||
def test_login_css_lives_in_one_section(self) -> None:
|
||||
self.assertIn("minmax(460px, 520px)", self.css)
|
||||
self.assertIn("width: min(440px, 100%)", self.css)
|
||||
self.assertIn("@media (max-width: 1023px)", self.css)
|
||||
self.assertIn("@media (max-width: 720px)", self.css)
|
||||
self.assertIn("minmax(400px, 440px)", self.css)
|
||||
self.assertNotIn(".entry-card", self.css)
|
||||
self.assertNotIn("0 0 60px rgba(55, 235, 137, 0.06)", self.css)
|
||||
login_block = self.css.split("/* Login */", 1)[1]
|
||||
after_login = login_block.split("/* ---- B-44", 1)[0] if "/* ---- B-44" in login_block else login_block
|
||||
self.assertEqual(1, after_login.count(".entry-shell { width: 100%;"))
|
||||
self.assertEqual(3, len(re.findall(r"\.entry-shell \{", after_login)))
|
||||
later = self.css.split("/* ---- B-44 intercompany balances ---- */", 1)[-1]
|
||||
self.assertNotIn(".entry-shell", later)
|
||||
self.assertNotIn(".entry-form {", later)
|
||||
self.assertNotIn(".role-switch {", later)
|
||||
|
||||
def test_role_switch_reuses_segmented_rhythm(self) -> None:
|
||||
self.assertIn(".role-switch { display: flex; gap: 4px; padding: 4px;", self.css)
|
||||
self.assertIn("min-height: 36px", self.css)
|
||||
self.assertIn('role="radiogroup"', self.html)
|
||||
self.assertIn("class=\"sr-only\"", self.html)
|
||||
|
||||
def test_auth_javascript_untouched(self) -> None:
|
||||
self.assertIn("function initEntry()", self.js)
|
||||
self.assertIn('fetch("/api/login"', self.js)
|
||||
self.assertIn('fetch("/api/password/change"', self.js)
|
||||
self.assertIn("must_change_password", self.js)
|
||||
self.assertIn("#togglePassword", self.js)
|
||||
self.assertIn("进入总账管理端", self.js)
|
||||
self.assertIn("进入公司业务端", self.js)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Chromium layout regression for the B-229 login page.
|
||||
|
||||
Measures dual-zone geometry, form width, overflow and overlap at the
|
||||
accepted viewports. Screenshots are written next to other visual evidence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.request import urlopen
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(Path(__file__).resolve().parent) not in sys.path:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from test_b44_layout import QuietHandler, _Cdp, chrome_bin # noqa: E402
|
||||
|
||||
SCREEN_DIR = ROOT / "screenshots"
|
||||
LOGIN_URL = "/web/index.html"
|
||||
MEASURE = r"""
|
||||
(() => {
|
||||
const box = (el) => {
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
left: r.left, right: r.right, top: r.top, bottom: r.bottom,
|
||||
width: r.width, height: r.height
|
||||
};
|
||||
};
|
||||
const overlapArea = (a, b) => {
|
||||
if (!a || !b) return 0;
|
||||
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
||||
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
||||
if (dx <= 0 || dy <= 0) return 0;
|
||||
return Math.round(dx * dy);
|
||||
};
|
||||
const context = document.querySelector(".entry-context");
|
||||
const wrap = document.querySelector(".entry-form-wrap");
|
||||
const form = document.querySelector(".entry-form");
|
||||
const role = document.querySelector(".role-switch");
|
||||
const spans = [...document.querySelectorAll(".role-switch span")];
|
||||
const brand = document.querySelector("#product-name");
|
||||
const status = document.querySelector(".entry-status");
|
||||
const statement = document.querySelector(".entry-statement h1");
|
||||
const ctx = box(context);
|
||||
const wr = box(wrap);
|
||||
const fm = box(form);
|
||||
const sp = spans.map(box);
|
||||
const clipped = (el) => Boolean(el && el.scrollWidth > el.clientWidth + 1);
|
||||
return {
|
||||
viewport: window.innerWidth,
|
||||
viewportH: window.innerHeight,
|
||||
grid: getComputedStyle(document.querySelector(".entry-shell")).gridTemplateColumns,
|
||||
contextBox: ctx,
|
||||
wrapBox: wr,
|
||||
formBox: fm,
|
||||
formWidth: fm ? Math.round(fm.width) : 0,
|
||||
roleHeight: role ? Math.round(role.getBoundingClientRect().height) : 0,
|
||||
roleHorizontal: sp.length === 2 && sp[0].right <= sp[1].left + 2,
|
||||
dualZone: Boolean(ctx && wr && ctx.right <= wr.left + 2 && Math.abs(ctx.top - wr.top) < 48),
|
||||
stacked: Boolean(ctx && wr && ctx.bottom <= wr.top + 8),
|
||||
overlap: overlapArea(ctx, fm),
|
||||
pageOverflowX: document.documentElement.scrollWidth - window.innerWidth,
|
||||
formBottom: fm ? fm.bottom : 0,
|
||||
formFitsViewport: Boolean(fm && fm.top >= -1 && fm.bottom <= window.innerHeight + 2),
|
||||
brandClipped: clipped(brand),
|
||||
statementClipped: clipped(statement),
|
||||
statusText: (status?.textContent || "").replace(/\s+/g, " ").trim(),
|
||||
periodText: (document.querySelector(".entry-facts")?.textContent || "").replace(/\s+/g, " ").trim(),
|
||||
hasToggle: Boolean(document.querySelector("#togglePassword")),
|
||||
hasError: Boolean(document.querySelector("#loginError")),
|
||||
changeHidden: document.querySelector("#changePassword")?.hidden === true,
|
||||
};
|
||||
})()
|
||||
"""
|
||||
|
||||
VIEWPORTS = (
|
||||
(1440, 900, "desktop", "b229-login-1440.png"),
|
||||
(1366, 768, "desktop", "b229-login-1366.png"),
|
||||
(1024, 768, "desktop", "b229-login-1024.png"),
|
||||
(768, 1024, "compact", "b229-login-768.png"),
|
||||
(375, 812, "mobile", "b229-login-375.png"),
|
||||
)
|
||||
|
||||
|
||||
class LoginLayoutTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.chrome = chrome_bin()
|
||||
cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
|
||||
cls.http_thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
||||
cls.http_thread.start()
|
||||
cls.port = cls.httpd.server_address[1]
|
||||
cls.tmp = tempfile.TemporaryDirectory(prefix="b229-login-", ignore_cleanup_errors=True)
|
||||
cls.proc = subprocess.Popen(
|
||||
[
|
||||
str(cls.chrome),
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--no-first-run",
|
||||
"--disable-extensions",
|
||||
"--hide-scrollbars",
|
||||
"--remote-debugging-port=0",
|
||||
f"--user-data-dir={cls.tmp.name}",
|
||||
"about:blank",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
port_file = Path(cls.tmp.name) / "DevToolsActivePort"
|
||||
deadline = time.time() + 15
|
||||
listing = None
|
||||
while time.time() < deadline:
|
||||
if port_file.exists() and port_file.stat().st_size:
|
||||
text = port_file.read_text(encoding="utf-8").strip().splitlines()
|
||||
if text:
|
||||
cls.debug_port = int(text[0])
|
||||
try:
|
||||
listing = json.loads(
|
||||
urlopen(f"http://127.0.0.1:{cls.debug_port}/json/list", timeout=5).read()
|
||||
)
|
||||
except Exception:
|
||||
listing = None
|
||||
if listing and any(item.get("type") == "page" for item in listing):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
raise RuntimeError("Chrome DevTools port not ready")
|
||||
page = next(item for item in listing if item.get("type") == "page")
|
||||
cls.cdp = _Cdp(page["webSocketDebuggerUrl"])
|
||||
cls.cdp.call("Runtime.enable")
|
||||
cls.cdp.call("Page.enable")
|
||||
SCREEN_DIR.mkdir(exist_ok=True)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
if getattr(cls, "cdp", None):
|
||||
cls.cdp.close()
|
||||
if getattr(cls, "proc", None):
|
||||
cls.proc.terminate()
|
||||
try:
|
||||
cls.proc.wait(timeout=5)
|
||||
except Exception:
|
||||
cls.proc.kill()
|
||||
cls.proc.wait(timeout=5)
|
||||
time.sleep(0.2)
|
||||
if getattr(cls, "httpd", None):
|
||||
cls.httpd.shutdown()
|
||||
cls.httpd.server_close()
|
||||
if getattr(cls, "tmp", None):
|
||||
cls.tmp.cleanup()
|
||||
|
||||
def _open(self, width: int, height: int) -> dict:
|
||||
self.cdp.call(
|
||||
"Emulation.setDeviceMetricsOverride",
|
||||
{
|
||||
"width": width,
|
||||
"height": height,
|
||||
"deviceScaleFactor": 1,
|
||||
"mobile": width <= 720,
|
||||
},
|
||||
)
|
||||
self.cdp.call("Page.navigate", {"url": f"http://127.0.0.1:{self.port}{LOGIN_URL}"})
|
||||
time.sleep(0.4)
|
||||
deadline = time.time() + 8
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
result = self.cdp.call("Runtime.evaluate", {"expression": MEASURE, "returnByValue": True})
|
||||
last = result.get("result", {}).get("value")
|
||||
if last and last.get("formWidth"):
|
||||
return last
|
||||
time.sleep(0.1)
|
||||
raise TimeoutError(f"login metrics not ready: {last}")
|
||||
|
||||
def _shot(self, name: str) -> None:
|
||||
raw = self.cdp.call("Page.captureScreenshot", {"format": "png", "captureBeyondViewport": False})
|
||||
(SCREEN_DIR / name).write_bytes(base64.b64decode(raw["data"]))
|
||||
|
||||
def test_login_viewports(self) -> None:
|
||||
for width, height, mode, shot in VIEWPORTS:
|
||||
with self.subTest(width=width, height=height, mode=mode):
|
||||
metrics = self._open(width, height)
|
||||
self._shot(shot)
|
||||
self.assertEqual(width, metrics["viewport"], metrics)
|
||||
self.assertEqual(0, metrics["overlap"], metrics)
|
||||
self.assertLessEqual(metrics["pageOverflowX"], 1, metrics)
|
||||
self.assertFalse(metrics["brandClipped"], metrics)
|
||||
self.assertFalse(metrics["statementClipped"], metrics)
|
||||
self.assertTrue(metrics["roleHorizontal"], metrics)
|
||||
self.assertLessEqual(metrics["roleHeight"], 52, metrics)
|
||||
self.assertIn("运行中", metrics["statusText"], metrics)
|
||||
self.assertIn("2026 年 7 月", metrics["periodText"], metrics)
|
||||
self.assertTrue(metrics["hasToggle"], metrics)
|
||||
self.assertTrue(metrics["hasError"], metrics)
|
||||
self.assertTrue(metrics["changeHidden"], metrics)
|
||||
if mode == "desktop":
|
||||
self.assertTrue(metrics["dualZone"], metrics)
|
||||
self.assertFalse(metrics["stacked"], metrics)
|
||||
self.assertGreaterEqual(metrics["formWidth"], 420, metrics)
|
||||
self.assertLessEqual(metrics["formWidth"], 460, metrics)
|
||||
self.assertTrue(metrics["formFitsViewport"], metrics)
|
||||
elif mode == "compact":
|
||||
self.assertTrue(metrics["dualZone"], metrics)
|
||||
self.assertFalse(metrics["stacked"], metrics)
|
||||
self.assertGreaterEqual(metrics["formWidth"], 360, metrics)
|
||||
self.assertLessEqual(metrics["formWidth"], 440, metrics)
|
||||
else:
|
||||
self.assertTrue(metrics["stacked"], metrics)
|
||||
self.assertFalse(metrics["dualZone"], metrics)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user