diff --git a/tests/fixtures/b44-balance-directory.html b/tests/fixtures/b44-balance-directory.html new file mode 100644 index 0000000..541c33d --- /dev/null +++ b/tests/fixtures/b44-balance-directory.html @@ -0,0 +1,138 @@ + + + + + + B-44 余额目录布局回归 + + + +
+ +
+
+
+

往来查询

公司间往来余额目录

+
+

公司余额目录

每行余额都附带截止日、期初状态、本期借贷、结果与未决金额

+
公司借方合计贷方合计期末结果未决截止日
+
+
+ + 甲公司CNY + CNY1,280.00 + CNY320.00 + 应收CNY960.00 + 未决 0.00 + 截止 2026.07.31 + + +
+
+ + 东南沿海综合贸易与供应链管理股份有限公司CNY + CNY123,456,789,012,345.00 + CNY100,000,000,000,000.00 + 应收CNY23,456,789,012,345.00 + 未决 2,150.00 · 3 笔手工记录待审 800.00 · 1 笔;待确认科目 1,350.00 · 2 笔 + 截止 2026.07.31 + + +
+
+
+
+
+
+
+ + + + + diff --git a/tests/test_b44_frontend.py b/tests/test_b44_frontend.py index 0eb2b9e..b7aba20 100644 --- a/tests/test_b44_frontend.py +++ b/tests/test_b44_frontend.py @@ -41,6 +41,11 @@ class B44FrontendContractTests(unittest.TestCase): def test_css_directory_and_drawer_breakpoints(self) -> None: self.assertIn("@media (max-width: 375px)", self.css) self.assertIn("@media (max-width: 1179px)", self.css) + self.assertIn("@media (min-width: 376px) and (max-width: 900px)", self.css) + self.assertIn( + "minmax(80px, 0.85fr) minmax(152px, 1.2fr) minmax(96px, 0.9fr) minmax(84px, 0.5fr) 24px", + self.css, + ) self.assertIn("@media (max-width: 767px)", self.css) self.assertIn(".drawer {", self.css) self.assertIn("width: 640px", self.css) diff --git a/tests/test_b44_layout.py b/tests/test_b44_layout.py new file mode 100644 index 0000000..4323a66 --- /dev/null +++ b/tests/test_b44_layout.py @@ -0,0 +1,530 @@ +"""Chromium layout regression for the B-44 admin balance directory. + +Measures 15-digit period-end amounts against the unresolved chip using +getBoundingClientRect — not static text presence. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +import socket +import subprocess +import tempfile +import threading +import time +from urllib.parse import urlparse +from urllib.request import urlopen +import unittest +import sys + +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)) +FIXTURE = "/tests/fixtures/b44-balance-directory.html" +CHROME_CANDIDATES = [ + Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) + / "Google/Chrome/Application/chrome.exe", + Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)")) + / "Microsoft/Edge/Application/msedge.exe", +] +VIEWPORTS = (720, 768, 800, 900, 375, 1024, 1440) + + +def chrome_bin() -> Path: + for path in CHROME_CANDIDATES: + if path.exists(): + return path + raise FileNotFoundError("Chrome/Edge not found for layout regression") + + +class QuietHandler(SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, directory=str(ROOT), **kwargs) + + def log_message(self, *_args) -> None: + pass + + def handle(self) -> None: + try: + super().handle() + except (ConnectionResetError, BrokenPipeError, TimeoutError): + pass + + +class _Cdp: + def __init__(self, ws_url: str) -> None: + parsed = urlparse(ws_url) + self._sock = socket.create_connection((parsed.hostname, parsed.port or 80), timeout=10) + self._sock.settimeout(10) + key = base64.b64encode(os.urandom(16)).decode() + path = parsed.path + (f"?{parsed.query}" if parsed.query else "") + self._sock.sendall( + ( + f"GET {path} HTTP/1.1\r\n" + f"Host: {parsed.netloc}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n\r\n" + ).encode() + ) + header = b"" + while b"\r\n\r\n" not in header: + chunk = self._sock.recv(4096) + if not chunk: + raise ConnectionError("CDP websocket handshake failed") + header += chunk + expected = base64.b64encode( + hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest() + ).decode() + if expected not in header.decode("latin1"): + raise ConnectionError("CDP websocket accept mismatch") + leftover = header.split(b"\r\n\r\n", 1)[1] + self._buf = leftover + self._next_id = 1 + + def call(self, method: str, params: dict | None = None, timeout: float = 15) -> dict: + msg_id = self._next_id + self._next_id += 1 + self._send({"id": msg_id, "method": method, "params": params or {}}) + deadline = time.time() + timeout + while time.time() < deadline: + payload = self._recv() + if payload.get("id") == msg_id: + if "error" in payload: + raise RuntimeError(f"{method}: {payload['error']}") + return payload.get("result") or {} + raise TimeoutError(method) + + def wait_event(self, name: str, timeout: float = 15) -> dict: + deadline = time.time() + timeout + while time.time() < deadline: + payload = self._recv() + if payload.get("method") == name: + return payload.get("params") or {} + raise TimeoutError(name) + + def close(self) -> None: + try: + self._sock.close() + except OSError: + pass + + def _send(self, obj: dict) -> None: + data = json.dumps(obj, separators=(",", ":")).encode() + mask = os.urandom(4) + header = bytearray([0x81]) + length = len(data) + if length < 126: + header.append(0x80 | length) + elif length < 65536: + header.append(0x80 | 126) + header.extend(length.to_bytes(2, "big")) + else: + header.append(0x80 | 127) + header.extend(length.to_bytes(8, "big")) + header.extend(mask) + masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data)) + self._sock.sendall(header + masked) + + def _recv(self) -> dict: + while True: + opcode, payload = self._read_frame() + if opcode == 0x9: + self._send_pong(payload) + continue + if opcode == 0xA: + continue + if opcode == 0x8: + raise ConnectionError("CDP websocket closed") + return json.loads(payload.decode()) + + def _send_pong(self, payload: bytes) -> None: + mask = os.urandom(4) + header = bytearray([0x8A, 0x80 | len(payload)]) + header.extend(mask) + masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + self._sock.sendall(header + masked) + + def _read_frame(self) -> tuple[int, bytes]: + header = self._read_exact(2) + opcode = header[0] & 0x0F + length = header[1] & 0x7F + masked = bool(header[1] & 0x80) + if length == 126: + length = int.from_bytes(self._read_exact(2), "big") + elif length == 127: + length = int.from_bytes(self._read_exact(8), "big") + mask = self._read_exact(4) if masked else b"" + payload = self._read_exact(length) + if masked: + payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + return opcode, payload + + def _read_exact(self, size: int) -> bytes: + while len(self._buf) < size: + chunk = self._sock.recv(4096) + if not chunk: + raise ConnectionError("CDP websocket closed") + self._buf += chunk + data, self._buf = self._buf[:size], self._buf[size:] + return data + + +class B44LayoutRegressionTests(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.url = f"http://127.0.0.1:{cls.port}{FIXTURE}" + cls.tmp = tempfile.TemporaryDirectory(prefix="b44-layout-", ignore_cleanup_errors=True) + user_dir = cls.tmp.name + cls.proc = subprocess.Popen( + [ + str(cls.chrome), + "--headless=new", + "--disable-gpu", + "--no-first-run", + "--disable-extensions", + "--remote-debugging-port=0", + f"--user-data-dir={user_dir}", + "about:blank", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + port_file = Path(user_dir) / "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") + + @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 subprocess.TimeoutExpired: + 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 _metrics(self, width: int) -> dict: + self.cdp.call("Emulation.setDeviceMetricsOverride", { + "width": width, + "height": 1100, + "deviceScaleFactor": 1, + "mobile": False, + }) + self.cdp.call("Page.navigate", {"url": f"{self.url}?w={width}"}) + deadline = time.time() + 10 + last = None + while time.time() < deadline: + result = self.cdp.call( + "Runtime.evaluate", + { + "expression": ( + "typeof window.measureB175 === 'function' " + "? (window.measureB175(), document.getElementById('b175-metrics').textContent) " + ": null" + ), + "returnByValue": True, + }, + ) + last = result.get("result", {}).get("value") + if last: + return json.loads(last) + time.sleep(0.1) + raise TimeoutError(f"layout metrics not ready: {last}") + + def test_fifteen_digit_amount_clear_of_unresolved_chip(self) -> None: + for width in VIEWPORTS: + with self.subTest(width=width): + metrics = self._metrics(width) + self.assertEqual(width, metrics["viewport"], metrics) + self.assertIn("23,456,789,012,345.00", metrics["amountText"]) + self.assertIn("未决", metrics["unresolvedText"]) + self.assertIn("2026.07.31", metrics["cutoffText"]) + self.assertTrue(metrics["amountVisible"]) + self.assertTrue(metrics["unresolvedVisible"]) + self.assertTrue(metrics["cutoffVisible"]) + self.assertFalse(metrics["amountClipped"], metrics) + self.assertFalse(metrics["unresolvedClipped"], metrics) + self.assertFalse(metrics["cutoffClipped"], metrics) + self.assertEqual(0, metrics["amountUnresolvedOverlap"], metrics) + self.assertEqual(0, metrics["amountCutoffOverlap"], metrics) + self.assertEqual(0, metrics["unresolvedCutoffOverlap"], metrics) + self.assertEqual(0, metrics["directionUnresolvedOverlap"], metrics) + self.assertEqual(0, metrics["cutoffChevronOverlap"], metrics) + self.assertFalse(metrics["drawerAmountClipped"], metrics) + if 376 <= width <= 1179: + self.assertTrue(metrics["fiveColVisible"], metrics) + if 376 <= width <= 900: + self.assertTrue(metrics["stackedResult"], metrics) + if width <= 375: + self.assertFalse(metrics["fiveColVisible"], metrics) + + +LIVE_MEASURE = r""" +(() => { + const pair = document.querySelector('[data-view="pair"]'); + if (pair) pair.click(); + const rows = [...document.querySelectorAll("#balanceLedgers .company-ledger.is-balances")]; + if (!rows.length) return null; + const row = rows.find((item) => (item.textContent || "").includes("123,456,789,012,345")) || rows[0]; + const amount = row.querySelector(".ledger-result .amount-with-currency"); + const direction = row.querySelector(".ledger-result .status"); + const unresolved = row.querySelector(".ledger-unresolved .status") || row.querySelector(".ledger-unresolved"); + const cutoff = row.querySelector(".ledger-cutoff"); + const chevron = row.querySelector("summary > svg"); + const box = (el) => { + const r = el.getBoundingClientRect(); + return { left: r.left, right: r.right, top: r.top, bottom: r.bottom }; + }; + const overlap = (a, b) => { + 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); + }; + const clipped = (el) => el.scrollWidth > el.clientWidth + 1; + const amountBox = box(amount); + const unresolvedBox = box(unresolved); + return { + viewport: window.innerWidth, + amountText: (amount.textContent || "").replace(/\s+/g, " ").trim(), + unresolvedText: (unresolved.textContent || "").replace(/\s+/g, " ").trim(), + cutoffText: (cutoff.textContent || "").replace(/\s+/g, " ").trim(), + amountUnresolvedOverlap: overlap(amountBox, unresolvedBox), + directionUnresolvedOverlap: overlap(box(direction), unresolvedBox), + amountCutoffOverlap: overlap(amountBox, box(cutoff)), + unresolvedCutoffOverlap: overlap(unresolvedBox, box(cutoff)), + cutoffChevronOverlap: overlap(box(cutoff), box(chevron)), + amountClipped: clipped(amount), + unresolvedClipped: clipped(unresolved), + cutoffClipped: clipped(cutoff), + fiveColVisible: getComputedStyle(document.querySelector(".ledger-head.is-balances")).display !== "none", + stackedResult: getComputedStyle(row.querySelector(".ledger-result")).flexDirection === "column", + rowCount: rows.length, + }; +})() +""" + +COMPANY_MEASURE = r""" +(() => { + const balances = document.querySelector('[data-view="balances"]'); + if (balances) balances.click(); + const row = document.querySelector(".company-row.is-balance-counterparty"); + if (!row) return null; + const amount = row.querySelector(".amount-with-currency") || row.querySelector(".company-row-figure strong"); + const direction = row.querySelector(".status"); + const cutoff = row.querySelector(".company-row-figure small"); + const box = (el) => { + const r = el.getBoundingClientRect(); + return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height }; + }; + const overlap = (a, b) => { + 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); + }; + return { + viewport: window.innerWidth, + amountText: (amount?.textContent || "").replace(/\s+/g, " ").trim(), + directionText: (direction?.textContent || "").replace(/\s+/g, " ").trim(), + cutoffText: (cutoff?.textContent || "").replace(/\s+/g, " ").trim(), + amountDirectionOverlap: amount && direction ? overlap(box(amount), box(direction)) : 0, + visible: Boolean(amount && amount.getBoundingClientRect().width > 4), + }; +})() +""" + + +class LiveAdminDirectoryLayoutTests(unittest.TestCase): + """Chromium against a real login/API page with B-168-scale 15-digit amounts.""" + + @classmethod + def setUpClass(cls) -> None: + from test_positions_api import IntercompanyApiTests + + cls.api = IntercompanyApiTests("test_admin_balances_directory") + cls.api.setUp() + confirmed = cls.api._fresh_pair("123456789012345.00") + cls.api._confirm(confirmed["ledger_event_id"]) + cls.api._fresh_pair("2150.00") + cls.tmp = tempfile.TemporaryDirectory(prefix="b44-live-", ignore_cleanup_errors=True) + cls.proc = subprocess.Popen( + [ + str(chrome_bin()), + "--headless=new", + "--disable-gpu", + "--no-first-run", + "--disable-extensions", + "--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") + cls.cdp.call("Network.enable") + token = cls.api.admin.cookies["cw_session"] + cls.cdp.call( + "Network.setCookie", + { + "name": "cw_session", + "value": token, + "url": f"http://127.0.0.1:{cls.api.port}/", + }, + ) + + @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 subprocess.TimeoutExpired: + cls.proc.kill() + cls.proc.wait(timeout=5) + time.sleep(0.2) + if getattr(cls, "api", None): + cls.api.tearDown() + cls.api.doCleanups() + if getattr(cls, "tmp", None): + cls.tmp.cleanup() + + def _eval(self, expression: str): + deadline = time.time() + 12 + last = None + while time.time() < deadline: + result = self.cdp.call( + "Runtime.evaluate", + {"expression": expression, "returnByValue": True}, + ) + last = result.get("result", {}).get("value") + if last: + return last + time.sleep(0.15) + raise TimeoutError(f"live page metrics not ready: {last}") + + def _open(self, path: str, width: int) -> None: + self.cdp.call("Emulation.setDeviceMetricsOverride", { + "width": width, + "height": 1100, + "deviceScaleFactor": 1, + "mobile": False, + }) + self.cdp.call("Page.navigate", {"url": f"http://127.0.0.1:{self.api.port}/{path}"}) + time.sleep(0.4) + + def test_live_admin_directory_viewports(self) -> None: + for width in (720, 768, 800, 900, 375, 1024, 1440): + with self.subTest(width=width): + self._open("admin.html", width) + metrics = self._eval(LIVE_MEASURE) + self.assertEqual(width, metrics["viewport"], metrics) + self.assertGreaterEqual(metrics["rowCount"], 1, metrics) + self.assertIn("123,456,789,012,345", metrics["amountText"], metrics) + self.assertIn("未决", metrics["unresolvedText"], metrics) + self.assertIn("2026.07.31", metrics["cutoffText"], metrics) + self.assertFalse(metrics["amountClipped"], metrics) + self.assertFalse(metrics["unresolvedClipped"], metrics) + self.assertEqual(0, metrics["amountUnresolvedOverlap"], metrics) + self.assertEqual(0, metrics["directionUnresolvedOverlap"], metrics) + self.assertEqual(0, metrics["amountCutoffOverlap"], metrics) + if 376 <= width <= 900: + self.assertTrue(metrics["stackedResult"], metrics) + if width <= 375: + self.assertFalse(metrics["fiveColVisible"], metrics) + + def test_live_company_balance_rows(self) -> None: + token = self.api.cashier_a.cookies["cw_session"] + self.cdp.call( + "Network.setCookie", + { + "name": "cw_session", + "value": token, + "url": f"http://127.0.0.1:{self.api.port}/", + }, + ) + for width in (375, 1440): + with self.subTest(width=width): + self._open("company.html", width) + metrics = self._eval(COMPANY_MEASURE) + self.assertTrue(metrics["visible"], metrics) + self.assertTrue(metrics["amountText"], metrics) + self.assertTrue(metrics["directionText"], metrics) + self.assertIn("截止", metrics["cutoffText"], metrics) + self.assertEqual(0, metrics["amountDirectionOverlap"], metrics) + + +if __name__ == "__main__": + unittest.main() + diff --git a/web/styles.css b/web/styles.css index 8655feb..4e9408a 100644 --- a/web/styles.css +++ b/web/styles.css @@ -770,17 +770,39 @@ body::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-event .company-ledger.is-balances .ledger-unresolved.is-active { color: var(--color-warning); } .company-ledger.is-balances .currency-tag { color: var(--color-ink-muted); font-size: 10px; } .company-ledger.is-balances .subject-row small { white-space: nowrap; } -@media (max-width: 900px) { - .ledger-head.is-balances { display: grid; } -} @media (max-width: 1179px) { .ledger-head.is-balances, .company-ledger.is-balances summary { - grid-template-columns: minmax(120px, 1.2fr) minmax(0, 0.9fr) minmax(0, 0.7fr) minmax(96px, 0.55fr) 24px; + grid-template-columns: minmax(120px, 1.2fr) minmax(136px, 0.9fr) minmax(0, 0.7fr) minmax(96px, 0.55fr) 24px; } .ledger-head.is-balances .ledger-hide-md, .company-ledger.is-balances summary .ledger-hide-md { display: none; } .company-ledger.is-balances summary .amount-with-currency .currency-code { display: none; } } +@media (min-width: 376px) and (max-width: 900px) { + .ledger-head.is-balances { display: grid; } + .ledger-head.is-balances, .company-ledger.is-balances summary { + grid-template-columns: minmax(80px, 0.85fr) minmax(152px, 1.2fr) minmax(96px, 0.9fr) minmax(84px, 0.5fr) 24px; + } + .company-ledger.is-balances summary .ledger-result { + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 2px; + } + .company-ledger.is-balances .ledger-result b { font-size: 11px; } + .company-ledger.is-balances .ledger-unresolved { + min-width: 0; + white-space: normal; + } + .company-ledger.is-balances .ledger-unresolved .status { + width: auto; + max-width: 100%; + height: auto; + white-space: normal; + justify-content: flex-start; + text-align: left; + } +} @media (max-width: 375px) { .ledger-head.is-balances { display: none; } .company-ledger.is-balances summary {