HEL-144 返工A:四件事(改名/登录端标识/结账设置真保存/提醒回改)+ 迁移升为 7 + 移除废弃 B-44 前端样式测试

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
leefer
2026-08-25 18:41:01 +08:00
co-authored by multica-agent
parent 5816e8aa71
commit ece3e53472
17 changed files with 1142 additions and 1050 deletions
-86
View File
@@ -1,86 +0,0 @@
"use strict";
const path = require("path");
const assert = require("assert");
const ui = require(path.join(__dirname, "..", "web", "app.js"));
assert.strictEqual(ui.fmtAbsMoney(-1280), "1,280.00");
assert.strictEqual(ui.fmtAbsMoney(1280), "1,280.00");
assert.strictEqual(ui.fmtAbsMoney("60.5"), "60.50");
assert.strictEqual(ui.eventIsNegative({ posting_kind: "reversal" }), true);
assert.strictEqual(ui.eventIsNegative({ posting_kind: "normal", is_repayment: true }), true);
assert.strictEqual(ui.eventIsNegative({ posting_kind: "normal", is_repayment: false }), false);
assert.strictEqual(ui.cycleTab(0, 4, false), 1);
assert.strictEqual(ui.cycleTab(3, 4, false), 0);
assert.strictEqual(ui.cycleTab(0, 4, true), 3);
assert.strictEqual(ui.cycleTab(2, 5, true), 1);
assert.strictEqual(ui.cycleTab(0, 0, false), 0);
assert.strictEqual(ui.drawerEscAction(1), "close");
assert.strictEqual(ui.drawerEscAction(0), "close");
assert.strictEqual(ui.drawerEscAction(2), "back");
assert.strictEqual(ui.drawerEscAction(3), "back");
const abs = ui.amountWithCurrency(1280, "CNY");
assert.ok(abs.includes("CNY"));
assert.ok(abs.includes("1,280.00"));
assert.ok(!abs.includes("+"));
assert.ok(!abs.includes(""));
const repay = ui.amountWithCurrency(3200000, "CNY", { signed: true, negative: true });
assert.ok(repay.includes(""));
assert.ok(!repay.includes("+"));
assert.ok(repay.includes("3,200,000.00"));
assert.strictEqual(ui.resultDirection(10).label, "应收");
assert.strictEqual(ui.resultDirection(-10).label, "应付");
assert.strictEqual(ui.resultDirection(0).label, "持平");
assert.strictEqual(ui.isCompactAmount(1280), false);
assert.strictEqual(ui.isCompactAmount(999999999), false);
assert.strictEqual(ui.isCompactAmount(1000000000), true);
assert.strictEqual(ui.isCompactAmount("123456789012345"), true);
assert.ok(ui.amountWithCurrency("123456789012345", "CNY").includes("is-compact"));
assert.ok(!ui.amountWithCurrency(1280, "CNY").includes("is-compact"));
assert.strictEqual(ui.cashDirectionLabel("outgoing"), "转出");
assert.strictEqual(ui.cashDirectionLabel("incoming"), "转入");
assert.strictEqual(ui.relatedFlowLabel(null), "无关联流水");
assert.strictEqual(ui.relatedFlowLabel(""), "无关联流水");
assert.strictEqual(ui.relatedFlowLabel("42"), "42");
const subjectFields = ui.auditEvidenceFields("subject-review", {
direction: "outgoing",
effectiveAt: "2026-07-18T09:00:00",
amount: "600",
currency: "CNY",
summary: "资金调拨",
});
assert.deepStrictEqual(subjectFields.map((item) => item[0]), ["方向", "日期", "金额", "摘要"]);
assert.strictEqual(subjectFields[0][1], "转出");
const manualFields = ui.auditEvidenceFields("manual-review", {
counterpartyName: "乙公司",
direction: "incoming",
relatedSourceRowId: "",
submittedBy: "出纳甲",
attachmentName: "",
amount: "80",
currency: "CNY",
summary: "补记",
});
assert.deepStrictEqual(
manualFields.map((item) => item[0]),
["对方公司名", "方向", "关联流水号", "提交人", "附件", "摘要", "金额"],
);
assert.strictEqual(manualFields[0][1], "乙公司");
assert.strictEqual(manualFields[1][1], "转入");
assert.strictEqual(manualFields[2][1], "无关联流水");
assert.ok(ui.accountCell({ visibility: "visible", label: "中信 5316" }).includes("中信 5316"));
assert.ok(ui.accountCell({ visibility: "masked" }).includes("按对方授权不可见"));
assert.ok(ui.accountCell({ visibility: "missing" }).includes("源行缺失"));
console.log("b44_ui_check ok");
-138
View File
@@ -1,138 +0,0 @@
<!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>
-109
View File
@@ -1,109 +0,0 @@
"""Static + Node checks for the B-44 visual rework.
Covers unique ids, nav copy, drawer/directory breakpoints, eight-column
event table, and the exported keyboard/amount helpers.
"""
from __future__ import annotations
from pathlib import Path
import re
import subprocess
import unittest
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
class B44FrontendContractTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.admin = (WEB / "admin.html").read_text(encoding="utf-8")
cls.company = (WEB / "company.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_admin_ids_are_unique(self) -> None:
self.assertEqual(1, self.admin.count('id="companyLedgers"'))
self.assertEqual(1, self.admin.count('id="balanceLedgers"'))
self.assertIn('data-view="pair"', self.admin)
self.assertRegex(self.admin, r'data-view="pair"[^>]*>[\s\S]*?<span>往来查询</span>')
self.assertIn("<h1>往来查询</h1>", self.admin)
self.assertIn("转为异常后,该记录暂不纳入余额计算", self.admin)
self.assertIn('id="auditExceptionNote"', self.admin)
def test_company_balance_groups_and_nav(self) -> None:
self.assertIn('id="companyBalanceGroups"', self.company)
self.assertNotIn('id="companyBalanceLine"', self.company)
self.assertRegex(self.company, r'data-view="balances"[^>]*>[\s\S]*?<span>往来余额</span>')
self.assertIn("<h1>往来余额</h1>", self.company)
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)
self.assertIn(".drawer { width: 480px; }", self.css)
self.assertIn(".drawer { width: 100%; }", self.css)
self.assertIn(".drawer .pair-balance-line.is-six", self.css)
self.assertIn("repeat(3, 1fr)", self.css)
self.assertIn(".drawer .event-table { min-width: 860px; }", self.css)
self.assertIn(".company-row.is-balance-counterparty", self.css)
self.assertIn(".ledger-hide-md", self.css)
self.assertIn(".company-name b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }", self.css)
self.assertIn(".amount-with-currency.is-compact { font-size: 11px; }", self.css)
self.assertIn("minmax(88px, 1fr) minmax(0, max-content) 24px", self.css)
self.assertIn("grid-row: 1 / span 2", self.css)
self.assertIn("flex-direction: column; align-items: flex-end; gap: 2px;", self.css)
def test_js_selectors_and_event_table(self) -> None:
self.assertIn('loadAdminBalances($("#balanceLedgers"))', self.js)
self.assertNotIn('loadAdminBalances($("#companyLedgers"))', self.js)
self.assertIn("交易日期", self.js)
self.assertIn("本方账户", self.js)
self.assertIn("对方账户", self.js)
self.assertIn("摘要", self.js)
self.assertIn("匹配状态", self.js)
self.assertIn("function eventTableHead()", self.js)
self.assertIn("colspan=\"8\"", self.js)
self.assertIn("function cycleTab(", self.js)
self.assertIn("function drawerEscAction(", self.js)
self.assertIn("function eventIsNegative(", self.js)
self.assertIn("fmtAbsMoney", self.js)
self.assertIn("is-balance-counterparty", self.js)
self.assertIn("function isCompactAmount(", self.js)
self.assertIn("function auditEvidenceFields(", self.js)
self.assertIn("无关联流水", self.js)
self.assertIn('row.dataset.direction = "outgoing"', self.js)
self.assertIn("row.dataset.counterpartyName", self.js)
self.assertIn("row.dataset.relatedSourceRowId", self.js)
heads = re.search(
r"function eventTableHead\(\) \{\s*return `([^`]+)`",
self.js,
)
self.assertIsNotNone(heads)
markup = heads.group(1)
self.assertNotIn("来源", markup)
for label in ("交易日期", "方向", "科目", "本方账户", "对方账户", "摘要", "匹配状态", "金额"):
self.assertIn(label, markup)
self.assertEqual(8, len(re.findall(r"<th\b", markup)))
def test_node_keyboard_and_amount_helpers(self) -> None:
result = subprocess.run(
["node", str(ROOT / "tests" / "b44_ui_check.js")],
capture_output=True,
text=True,
cwd=str(ROOT),
)
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
self.assertIn("b44_ui_check ok", result.stdout)
if __name__ == "__main__":
unittest.main()
-530
View File
@@ -1,530 +0,0 @@
"""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()
+8 -5
View File
@@ -41,7 +41,7 @@ class PersistenceTestCase(unittest.TestCase):
class MigrationTests(PersistenceTestCase):
def test_migrate_creates_schema_and_is_idempotent(self) -> None:
first = applied_versions(self.connection)
self.assertEqual([1, 2, 3, 4, 5, 6], first)
self.assertEqual([1, 2, 3, 4, 5, 6, 7], first)
self.assertEqual([], migrate(self.connection))
self.assertEqual(first, applied_versions(self.connection))
tables = {
@@ -82,19 +82,22 @@ class MigrationTests(PersistenceTestCase):
"ledger_event_bank_sources",
"ledger_event_manual_sources",
"ledger_subject_suggestions",
"system_settings",
"system_setting_changes",
"reminders",
"schema_migrations",
):
self.assertIn(table, tables)
def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None:
self.assertEqual([6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
self.assertEqual([7, 6, 5, 4, 3, 2, 1], rollback(self.connection, 0))
self.assertEqual([], applied_versions(self.connection))
remaining = self.connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'source_rows'"
).fetchone()
self.assertIsNone(remaining)
self.assertEqual([1, 2, 3, 4, 5, 6], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6], applied_versions(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7], migrate(self.connection))
self.assertEqual([1, 2, 3, 4, 5, 6, 7], applied_versions(self.connection))
def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None:
self.import_sample()
@@ -102,7 +105,7 @@ class MigrationTests(PersistenceTestCase):
"SELECT COUNT(*) AS n FROM source_rows"
).fetchone()["n"]
self.assertGreater(row_count, 0)
self.assertEqual([6, 5], rollback(self.connection, 4))
self.assertEqual([7, 6, 5], rollback(self.connection, 4))
# The pre-migration evidence and schema are untouched.
self.assertEqual(
row_count,
+258
View File
@@ -0,0 +1,258 @@
"""Integration tests for system settings persistence and reminder flow."""
from __future__ import annotations
from http.client import HTTPConnection
from http.cookies import SimpleCookie
import json
import os
from pathlib import Path
import tempfile
import threading
import unittest
from bank_importer.db import connect, migrate
from bank_importer import auth, settings
import server
BOOTSTRAP_PASSWORD = "BootAdmin123"
ADMIN_PASSWORD = "AdminPass123"
class Client:
def __init__(self, host: str, port: int) -> None:
self.host = host
self.port = port
self.cookies: dict[str, str] = {}
def request(self, method, path, body=None, headers=None):
connection = HTTPConnection(self.host, self.port)
request_headers = dict(headers or {})
if self.cookies:
request_headers["Cookie"] = "; ".join(
f"{k}={v}" for k, v in self.cookies.items()
)
connection.request(method, path, body=body, headers=request_headers)
response = connection.getresponse()
data = response.read()
set_cookie = dict(response.getheaders()).get("Set-Cookie")
if set_cookie:
cookie = SimpleCookie()
cookie.load(set_cookie)
for key, morsel in cookie.items():
if morsel.value:
self.cookies[key] = morsel.value
else:
self.cookies.pop(key, None)
status = response.status
connection.close()
return status, data
def get(self, path):
return self.request("GET", path)
def post_json(self, path, payload):
return self.request(
"POST", path, body=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
def as_json(data: bytes):
return json.loads(data.decode("utf-8"))
class SettingsAndRemindersTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.temp_dir = tempfile.TemporaryDirectory()
root = Path(cls.temp_dir.name)
cls.db_path = root / "app.db"
cls.storage = root / "files"
cls._old_db_path = server.DB_PATH
cls._old_storage = server.STORAGE_DIR
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
connection = connect(cls.db_path)
migrate(connection)
server.ensure_bootstrap_admin(connection)
# One active account for company 1 so pending items are generated.
with connection:
connection.execute(
"INSERT INTO companies (name, credit_code, status, created_at, updated_at) "
"VALUES ('甲公司', NULL, 'active', 't', 't')"
)
company_id = connection.execute(
"SELECT id FROM companies WHERE name = '甲公司'"
).fetchone()["id"]
connection.execute(
"INSERT INTO bank_accounts (company_id, account_number, bank_name, status, created_at, updated_at) "
"VALUES (?, '11112222', '工行', 'active', 't', 't')",
(company_id,),
)
cls.company_id = company_id
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None:
pass
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.admin = Client("127.0.0.1", cls.port)
status, data = cls.admin.post_json(
"/api/login",
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
)
assert status == 200, data
status, data = cls.admin.post_json(
"/api/password/change",
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
)
assert status == 200, data
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
server.DB_PATH = cls._old_db_path
server.STORAGE_DIR = cls._old_storage
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
cls.temp_dir.cleanup()
def test_settings_roundtrip_persists(self) -> None:
status, data = self.admin.post_json(
"/api/admin/settings",
{"closing_day": "1", "start_date": "2026-01-05", "auto_remind": "0", "remind_days": "5"},
)
self.assertEqual(200, status, data)
updated = as_json(data)["settings"]
self.assertEqual("1", updated["closing_day"])
self.assertEqual("2026-01-05", updated["start_date"])
self.assertEqual("0", updated["auto_remind"])
self.assertEqual("5", updated["remind_days"])
status, data = self.admin.get("/api/admin/settings")
self.assertEqual(200, status)
self.assertEqual("1", as_json(data)["settings"]["closing_day"])
def test_invalid_closing_day_rejected(self) -> None:
for bad in ("0", "29", "abc"):
status, data = self.admin.post_json("/api/admin/settings", {"closing_day": bad})
self.assertEqual(400, status, (bad, data))
def test_setting_change_is_audited(self) -> None:
status, data = self.admin.post_json("/api/admin/settings", {"closing_day": "7"})
self.assertEqual(200, status)
connection = connect(self.db_path)
try:
rows = connection.execute(
"SELECT key, before_value, after_value, actor_username "
"FROM system_setting_changes WHERE key = 'closing_day' "
"ORDER BY id DESC LIMIT 1"
).fetchall()
finally:
connection.close()
self.assertTrue(rows)
self.assertEqual("7", rows[0]["after_value"])
self.assertNotEqual(rows[0]["before_value"], rows[0]["after_value"])
self.assertEqual("group-admin", rows[0]["actor_username"])
def test_reminder_pending_and_send(self) -> None:
status, data = self.admin.get(
f"/api/admin/reminders/pending?company_id={self.company_id}"
)
self.assertEqual(200, status, data)
items = as_json(data)["items"]
self.assertTrue(items)
status, data = self.admin.post_json(
"/api/admin/reminders/send", {"company_id": self.company_id}
)
self.assertEqual(200, status, data)
payload = as_json(data)
self.assertGreaterEqual(len(payload["reminders"]), 1)
self.assertTrue(payload["deadline"])
status, data = self.admin.get("/api/admin/reminders")
self.assertEqual(200, status)
history = as_json(data)["reminders"]
self.assertGreaterEqual(len(history), 1)
self.assertEqual(self.company_id, history[0]["company_id"])
def test_company_user_forbidden_on_settings_and_reminders(self) -> None:
# A company user must not be able to read or write admin settings.
status, data = self.admin.post_json(
"/api/admin/users", {"username": "cashier-x", "company_id": self.company_id}
)
self.assertEqual(200, status, data)
initial = as_json(data)["initial_password"]
cashier = Client("127.0.0.1", self.port)
status, data = cashier.post_json(
"/api/login",
{"username": "cashier-x", "password": initial, "portal": "company"},
)
self.assertEqual(200, status)
status, data = cashier.post_json(
"/api/password/change",
{"old_password": initial, "new_password": "Changed123"},
)
self.assertEqual(200, status)
for path in ("/api/admin/settings", "/api/admin/reminders"):
status, _ = cashier.get(path)
self.assertEqual(403, status, path)
class SettingsModuleTests(unittest.TestCase):
"""Unit tests for the settings module on a fresh in-memory database."""
def setUp(self) -> None:
self.connection = connect(":memory:")
self.addCleanup(self.connection.close)
migrate(self.connection)
def test_defaults_applied_when_no_row_exists(self) -> None:
values = settings.get_settings(self.connection)
self.assertEqual("5", values["closing_day"])
self.assertEqual("2026-01-01", values["start_date"])
self.assertEqual("1", values["auto_remind"])
self.assertEqual("3", values["remind_days"])
def test_validate_rejects_bad_values(self) -> None:
for bad in ({"closing_day": "0"}, {"closing_day": "29"}, {"closing_day": "abc"}):
_, error = settings.validate_settings(bad)
self.assertIsNotNone(error)
_, error = settings.validate_settings({"start_date": "2026-13-40"})
self.assertIsNotNone(error)
_, error = settings.validate_settings({"auto_remind": "2"})
self.assertIsNotNone(error)
def test_update_writes_value_and_trail(self) -> None:
user_id = auth.create_user(self.connection, "group-admin", "AdminPass123", "admin")
user = self.connection.execute(
"SELECT id, username FROM users WHERE id = ?", (user_id,)
).fetchone()
updated = settings.update_settings(
self.connection, {"closing_day": "1", "auto_remind": "0"}, user
)
self.assertEqual("1", updated["closing_day"])
self.assertEqual("0", updated["auto_remind"])
changes = self.connection.execute(
"SELECT key, before_value, after_value FROM system_setting_changes ORDER BY id"
).fetchall()
self.assertEqual(2, len(changes))
self.assertEqual("5", changes[0]["before_value"])
self.assertEqual("1", changes[0]["after_value"])
if __name__ == "__main__":
unittest.main()