B-44: 返修——768px 超大期末金额与未决状态避让
在 376–900px 五列目录把方向 chip 与期末金额分行,并加 Chromium 盒模型回归,避免 15 位金额盖住未决状态。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+138
@@ -0,0 +1,138 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>B-44 余额目录布局回归</title>
|
||||
<link rel="stylesheet" href="../../web/styles.css" />
|
||||
</head>
|
||||
<body data-portal="admin">
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" id="sidebar" aria-label="总账管理导航">
|
||||
<div class="brand"><span class="brand-mark">金</span><span class="brand-copy"><strong>金牛集团</strong><small>总账管理端</small></span></div>
|
||||
<nav class="nav-list">
|
||||
<button class="nav-item is-active" type="button"><span>往来查询</span></button>
|
||||
</nav>
|
||||
</aside>
|
||||
<div class="workspace">
|
||||
<main id="main-content">
|
||||
<section class="app-view is-active">
|
||||
<header class="page-heading"><div><h1>往来查询</h1><p>公司间往来余额目录</p></div></header>
|
||||
<section class="panel company-ledger-panel" aria-label="公司余额目录">
|
||||
<div class="panel-heading"><div><h2>公司余额目录</h2><p>每行余额都附带截止日、期初状态、本期借贷、结果与未决金额</p></div></div>
|
||||
<div class="ledger-head is-balances"><span>公司</span><span class="ledger-hide-md">借方合计</span><span class="ledger-hide-md">贷方合计</span><span>期末结果</span><span>未决</span><span>截止日</span><span></span></div>
|
||||
<div class="company-ledgers">
|
||||
<details class="company-ledger is-balances">
|
||||
<summary>
|
||||
<span class="company-name" title="甲公司"><i>甲</i><b>甲公司</b><small class="currency-tag">CNY</small></span>
|
||||
<strong class="amount debit ledger-hide-md"><span class="amount-with-currency"><span class="currency-code">CNY</span>1,280.00</span></strong>
|
||||
<strong class="amount credit ledger-hide-md"><span class="amount-with-currency"><span class="currency-code">CNY</span>320.00</span></strong>
|
||||
<span class="ledger-result"><em class="status success">应收</em><b><span class="amount-with-currency"><span class="currency-code">CNY</span>960.00</span></b></span>
|
||||
<span class="ledger-unresolved is-empty"><span class="status neutral">未决 0.00</span></span>
|
||||
<span class="ledger-cutoff">截止 2026.07.31</span>
|
||||
<svg></svg>
|
||||
</summary>
|
||||
</details>
|
||||
<details class="company-ledger is-balances" id="stressRow">
|
||||
<summary>
|
||||
<span class="company-name" title="东南沿海综合贸易与供应链管理股份有限公司"><i>东</i><b>东南沿海综合贸易与供应链管理股份有限公司</b><small class="currency-tag">CNY</small></span>
|
||||
<strong class="amount debit ledger-hide-md"><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>123,456,789,012,345.00</span></strong>
|
||||
<strong class="amount credit ledger-hide-md"><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>100,000,000,000,000.00</span></strong>
|
||||
<span class="ledger-result"><em class="status success">应收</em><b><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>23,456,789,012,345.00</span></b></span>
|
||||
<span class="ledger-unresolved is-active"><span class="status warning">未决 2,150.00 · 3 笔</span><small style="display:block;color:var(--color-ink-muted);font-size:10px">手工记录待审 800.00 · 1 笔;待确认科目 1,350.00 · 2 笔</small></span>
|
||||
<span class="ledger-cutoff">截止 2026.07.31</span>
|
||||
<svg></svg>
|
||||
</summary>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="drawer is-open" id="evidenceDrawer">
|
||||
<header class="drawer-header">
|
||||
<div>
|
||||
<div class="drawer-breadcrumb"><button type="button">往来查询</button> / 甲公司 ↔ 乙公司</div>
|
||||
<h2>甲公司 ↔ 乙公司</h2>
|
||||
</div>
|
||||
<button type="button" class="icon-button" aria-label="关闭">×</button>
|
||||
</header>
|
||||
<div class="drawer-body">
|
||||
<div class="pair-balance-line is-six" id="drawerSix">
|
||||
<div><span>期初余额</span><strong class="amount-neutral">期初不可用</strong></div>
|
||||
<div><span>本期借方</span><strong><span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>123,456,789,012,345.00</span></strong></div>
|
||||
<div><span>本期贷方</span><strong><span class="amount-with-currency"><span class="currency-code">CNY</span>320.00</span></strong></div>
|
||||
<div class="pair-final"><span>期末结果</span><strong><em class="status success">应收</em> <span class="amount-with-currency is-compact"><span class="currency-code">CNY</span>23,456,789,012,345.00</span></strong></div>
|
||||
<div class="pair-unresolved is-empty"><span>未决金额</span><strong><span class="amount-with-currency"><span class="currency-code">CNY</span>0.00</span></strong></div>
|
||||
<div><span>截止日</span><strong class="amount-neutral">2026.07.31</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<pre id="b175-metrics" hidden></pre>
|
||||
<script>
|
||||
function 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 };
|
||||
}
|
||||
function 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(Math.min(dx, dy) === dy ? dx : dx);
|
||||
}
|
||||
function clipped(el) {
|
||||
return el.scrollWidth > el.clientWidth + 1;
|
||||
}
|
||||
function measure() {
|
||||
const row = document.querySelector("#stressRow");
|
||||
const summary = row.querySelector("summary");
|
||||
const amount = row.querySelector(".ledger-result .amount-with-currency");
|
||||
const direction = row.querySelector(".ledger-result .status");
|
||||
const unresolved = row.querySelector(".ledger-unresolved .status");
|
||||
const cutoff = row.querySelector(".ledger-cutoff");
|
||||
const chevron = row.querySelector("summary > svg");
|
||||
const head = document.querySelector(".ledger-head.is-balances");
|
||||
const drawerAmount = document.querySelector("#drawerSix .pair-final .amount-with-currency");
|
||||
const amountBox = box(amount);
|
||||
const unresolvedBox = box(unresolved);
|
||||
const cutoffBox = box(cutoff);
|
||||
const chevronBox = box(chevron);
|
||||
const directionBox = box(direction);
|
||||
const metrics = {
|
||||
viewport: window.innerWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
fiveColVisible: getComputedStyle(head).display !== "none",
|
||||
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),
|
||||
amountCutoffOverlap: overlap(amountBox, cutoffBox),
|
||||
unresolvedCutoffOverlap: overlap(unresolvedBox, cutoffBox),
|
||||
unresolvedChevronOverlap: overlap(unresolvedBox, chevronBox),
|
||||
cutoffChevronOverlap: overlap(cutoffBox, chevronBox),
|
||||
directionUnresolvedOverlap: overlap(directionBox, unresolvedBox),
|
||||
amountClipped: clipped(amount),
|
||||
unresolvedClipped: clipped(unresolved),
|
||||
cutoffClipped: clipped(cutoff),
|
||||
amountVisible: amountBox.width > 4 && amountBox.height > 4,
|
||||
unresolvedVisible: unresolvedBox.width > 4 && unresolvedBox.height > 4,
|
||||
cutoffVisible: cutoffBox.width > 4 && cutoffBox.height > 4,
|
||||
stackedResult: getComputedStyle(row.querySelector(".ledger-result")).flexDirection === "column",
|
||||
drawerAmountClipped: clipped(drawerAmount),
|
||||
summaryWidth: Math.round(box(summary).width),
|
||||
};
|
||||
const node = document.getElementById("b175-metrics");
|
||||
node.hidden = false;
|
||||
node.textContent = JSON.stringify(metrics);
|
||||
document.title = "B175 " + node.textContent;
|
||||
}
|
||||
window.measureB175 = measure;
|
||||
if (document.readyState === "complete") {
|
||||
measure();
|
||||
} else {
|
||||
window.addEventListener("load", measure);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
+26
-4
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user