Files
caiwuzongzhang/tests/test_hel203_browser_smoke.py
T
fe6b4e59ec HEL-203: 修复原因弹窗作用域、首屏断档与表单 reset
将 openModal/closeModal 提升为顶层唯一定义,公司端启动即加载断档提醒,
并在 await 前提取表单引用;补充 Playwright 两端冒烟与源码契约测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-27 17:29:07 +00:00

519 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""HEL-203: 真实浏览器冒烟——原因弹窗、首屏断档、表单 reset。"""
from __future__ import annotations
import os
import re
import tempfile
import threading
import unittest
from pathlib import Path
from bank_importer import auth, calculation, matching, master_data
from bank_importer.db import connect, migrate, utc_now
import server
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
ADMIN_PASSWORD = "AdminPass123"
CASHIER_PASSWORD = "CashierA123"
try:
from playwright.sync_api import sync_playwright
except ImportError: # pragma: no cover
sync_playwright = None
def _prepare_chrome_libs() -> Path | None:
"""本机缺系统 atk 时,复用仓库旁的本地 chromium 依赖目录。"""
candidates = [ROOT / ".chrome-libs" / "lib"]
for lib_dir in candidates:
if (lib_dir / "libatk-1.0.so.0").exists():
current = os.environ.get("LD_LIBRARY_PATH", "")
prefix = str(lib_dir)
if prefix not in current.split(":"):
os.environ["LD_LIBRARY_PATH"] = (
f"{prefix}:{current}" if current else prefix
)
return lib_dir
return None
def _chromium_available() -> bool:
if not sync_playwright:
return False
_prepare_chrome_libs()
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
browser.close()
return True
except Exception:
return False
class Hel203SourceContractTests(unittest.TestCase):
"""不依赖浏览器:锁住 N1N3 的源码契约。"""
def test_open_modal_is_single_top_level(self) -> None:
js = (WEB / "app.js").read_text(encoding="utf-8")
defs = list(re.finditer(r"(?m)^function openModal\(", js))
self.assertEqual(1, len(defs), "openModal 必须只有一处顶层定义")
# 不得再出现在 initAdmin / initCompany 函数体内的局部副本
self.assertNotRegex(
js,
r"function initAdmin\(\)[\s\S]*?function openModal\(",
)
self.assertNotRegex(
js,
r"function initCompany\(\)[\s\S]*?function openModal\(",
)
self.assertIn("function askReason(", js)
ask_pos = js.index("function askReason(")
open_pos = defs[0].start()
self.assertLess(open_pos, ask_pos, "openModal 须在 askReason 之前定义")
def test_init_company_boots_coverage_gaps(self) -> None:
js = (WEB / "app.js").read_text(encoding="utf-8")
company_fn = js[js.index("function initCompany(") :]
boot = company_fn[: company_fn.index("\nif (portal ===")]
self.assertIn("await loadCompanyWorkspace()", boot)
self.assertIn("await loadCompanyCoverageGaps()", boot)
def test_async_forms_capture_form_before_await(self) -> None:
js = (WEB / "app.js").read_text(encoding="utf-8")
self.assertNotIn("event.currentTarget.reset()", js)
for marker in ("#companyForm", "#openingForm", "#accountForm"):
idx = js.index(marker)
chunk = js[idx : idx + 2500]
self.assertIn("const form = event.currentTarget", chunk)
self.assertIn("form.reset()", chunk)
@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过浏览器冒烟")
@unittest.skipUnless(_chromium_available(), "chromium 无法启动,跳过浏览器冒烟")
class Hel203BrowserSmokeTests(unittest.TestCase):
"""真实 Chromium:改起算日→期初→公司端期末;断档首屏→说明→审核。"""
@classmethod
def setUpClass(cls) -> None:
_prepare_chrome_libs()
cls.temp_dir = tempfile.TemporaryDirectory()
root = Path(cls.temp_dir.name)
cls.db_path = root / "app.db"
cls.storage = root / "files"
cls.storage.mkdir()
cls._old_db = server.DB_PATH
cls._old_storage = server.STORAGE_DIR
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage
connection = connect(cls.db_path)
migrate(connection)
auth.create_user(
connection,
"group-admin",
ADMIN_PASSWORD,
"admin",
must_change_password=False,
)
cls.company_a = master_data.create_company(
connection, "甲公司", None, None, None
)
cls.company_b = master_data.create_company(
connection, "乙公司", None, None, None
)
auth.create_user(
connection,
"cashier-a",
CASHIER_PASSWORD,
"company",
cls.company_a,
must_change_password=False,
)
admin = connection.execute(
"SELECT * FROM users WHERE username = 'group-admin'"
).fetchone()
account = master_data.submit_bank_account(
connection,
company_id=cls.company_a,
bank_name="中信银行",
account_type="基本户",
account_number="6222000000000001",
start_date="2026-06-01",
actor=None,
)
cls.account_a = master_data.review_bank_account(
connection,
account["id"],
"approve",
None,
admin,
effective_from="2026-06-01",
)
account_b = master_data.submit_bank_account(
connection,
company_id=cls.company_b,
bank_name="中信银行",
account_type="基本户",
account_number="6222000000000002",
start_date="2026-06-01",
actor=None,
)
master_data.review_bank_account(
connection,
account_b["id"],
"approve",
None,
admin,
effective_from="2026-06-01",
)
# 制造一处 mid 断档,供公司端首屏提醒
with connection:
cursor = connection.execute(
"""
INSERT INTO source_files
(sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, 'gap.xlsx', 1, 'data/files/gap.xlsx', ?)
""",
("sha-hel203-gap", utc_now()),
)
source_file_id = int(cursor.lastrowid)
cursor = connection.execute(
"""
INSERT INTO import_batches (
source_file_id, status, company_id, upload_bank_account_id,
created_at, updated_at
) VALUES (?, 'parsed', ?, ?, ?, ?)
""",
(
source_file_id,
cls.company_a,
cls.account_a["id"],
utc_now(),
utc_now(),
),
)
batch_id = int(cursor.lastrowid)
cursor = connection.execute(
"""
INSERT INTO sheet_batches (
import_batch_id, sheet_name, bank_name, template_id,
template_version, header_row, transaction_count, warnings,
created_at
) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
""",
(batch_id, utc_now()),
)
sheet_batch_id = int(cursor.lastrowid)
connection.execute(
"""
INSERT INTO sheet_reviews (
import_batch_id, sheet_name, outcome, sheet_batch_id,
review_status, created_at
) VALUES (?, '流水', 'parsed', ?, 'confirmed', ?)
""",
(batch_id, sheet_batch_id, utc_now()),
)
connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense,
own_account, created_at
) VALUES (?, 1, '2026-06-21T10:00:00', '0', '0', ?, ?)
""",
(sheet_batch_id, "6222000000000001", utc_now()),
)
calculation.set_calculation_start_date(
connection, "2026-06-01", "初始化起算", admin
)
calculation.recalculate_coverage_gaps(connection)
# 种一笔已确认往来,确认期初后公司端才能进入完整期末口径
def _add_row(company_id, account_id, own, cp, *, income, expense, at, ref):
with connection:
cursor = connection.execute(
"""
INSERT INTO source_files
(sha256, original_filename, size_bytes, storage_path, created_at)
VALUES (?, 'xfer.xlsx', 1, 'data/files/xfer.xlsx', ?)
""",
(ref, utc_now()),
)
source_file_id = int(cursor.lastrowid)
cursor = connection.execute(
"""
INSERT INTO import_batches (
source_file_id, status, company_id, upload_bank_account_id,
created_at, updated_at
) VALUES (?, 'parsed', ?, ?, ?, ?)
""",
(source_file_id, company_id, account_id, utc_now(), utc_now()),
)
batch_id = int(cursor.lastrowid)
cursor = connection.execute(
"""
INSERT INTO sheet_batches (
import_batch_id, sheet_name, bank_name, template_id,
template_version, header_row, transaction_count, warnings,
created_at
) VALUES (?, '流水', '测试银行', 'test-v1', 1, 1, 1, '[]', ?)
""",
(batch_id, utc_now()),
)
sheet_batch_id = int(cursor.lastrowid)
connection.execute(
"""
INSERT INTO sheet_reviews (
import_batch_id, sheet_name, outcome, sheet_batch_id,
review_status, created_at
) VALUES (?, '流水', 'parsed', ?, 'confirmed', ?)
""",
(batch_id, sheet_batch_id, utc_now()),
)
cursor = connection.execute(
"""
INSERT INTO source_rows (
sheet_batch_id, source_row, transaction_at, income, expense,
own_account, own_name, counterparty_account, counterparty_name,
summary, purpose, currency, created_at
) VALUES (?, 1, ?, ?, ?, ?, '测试', ?, '对方', '往来', '往来款', 'CNY', ?)
""",
(
sheet_batch_id,
at,
income,
expense,
own,
cp,
utc_now(),
),
)
return int(cursor.lastrowid)
row_a = _add_row(
cls.company_a,
cls.account_a["id"],
"6222000000000001",
"6222000000000002",
income="0",
expense="100.00",
at="2026-06-20T10:00:00",
ref="sha-hel203-a",
)
row_b = _add_row(
cls.company_b,
account_b["id"],
"6222000000000002",
"6222000000000001",
income="100.00",
expense="0",
at="2026-06-20T11:00:00",
ref="sha-hel203-b",
)
matching.reconcile_rows(connection, [row_a, row_b])
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.base = f"http://127.0.0.1:{cls.port}"
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
server.DB_PATH = cls._old_db
server.STORAGE_DIR = cls._old_storage
cls.temp_dir.cleanup()
def _new_page(self, playwright):
browser = playwright.chromium.launch(
headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"]
)
context = browser.new_context(viewport={"width": 1440, "height": 900})
page = context.new_page()
errors: list[str] = []
page.on("pageerror", lambda err: errors.append(str(err)))
page.on(
"console",
lambda msg: errors.append(f"console.{msg.type}: {msg.text}")
if msg.type == "error"
else None,
)
return browser, page, errors
def _login(self, page, *, portal: str, username: str, password: str) -> None:
login_path = "login-admin.html" if portal == "admin" else "login-company.html"
page.goto(f"{self.base}/{login_path}", wait_until="domcontentloaded")
page.fill("#account", username)
page.fill("#password", password)
page.click('button[type="submit"]')
expect = "admin.html" if portal == "admin" else "company.html"
page.wait_for_url(f"**/{expect}", timeout=15000)
def test_02_admin_start_date_opening_company_ending(self) -> None:
with sync_playwright() as p:
browser, page, errors = self._new_page(p)
try:
self._login(
page,
portal="admin",
username="group-admin",
password=ADMIN_PASSWORD,
)
page.click('a[data-view="settings"]')
page.wait_for_selector("#cs-start", state="visible")
# 修改起算日 → 原因弹窗必须打开且发出 PUT
page.fill("#cs-start", "2026-06-15")
with page.expect_request(
lambda req: req.method == "PUT"
and "/api/admin/settings/calculation-start" in req.url
) as start_req:
page.click('#systemSettings button[type="submit"]')
page.wait_for_selector("#reasonDialog.open", timeout=5000)
page.fill("#reasonInput", "调整起算日供冒烟")
page.click("#reasonSubmit")
self.assertTrue(start_req.value.post_data)
page.wait_for_function(
"() => document.getElementById('cs-start')?.value === '2026-06-15'"
)
# 创建期初并确认
page.click("#openOpeningDialog")
page.wait_for_selector("#openingDialog.open")
page.select_option("#ob-from", label="甲公司")
page.select_option("#ob-to", label="乙公司")
page.fill("#ob-amount", "200")
page.fill("#ob-reason", "冒烟期初录入")
with page.expect_request(
lambda req: req.method == "POST"
and req.url.endswith("/api/admin/opening-balances")
):
page.click('#openingForm button[type="submit"]')
page.wait_for_selector(
'#openingRows button[data-confirm-opening]',
timeout=8000,
)
with page.expect_request(
lambda req: req.method == "POST"
and "/opening-balances/" in req.url
and req.url.endswith("/confirm")
):
page.click('#openingRows button[data-confirm-opening]')
page.wait_for_selector("#reasonDialog.open", timeout=5000)
page.fill("#reasonInput", "确认期初冒烟")
page.click("#reasonSubmit")
page.wait_for_selector(
'#openingRows button[data-void-opening]',
timeout=8000,
)
# 公司端看到完整期末口径
self._login(
page,
portal="company",
username="cashier-a",
password=CASHIER_PASSWORD,
)
page.click('a[data-view="transfers"]')
page.wait_for_function(
"""() => {
const data = document.getElementById('transfersData');
const empty = document.getElementById('transfersEmpty');
const ready = (data && !data.hidden) || (empty && !empty.hidden);
const card = document.getElementById('tfStatEndingCard');
const title = document.getElementById('tfStatNetTitle');
const emptyHtml = document.getElementById('transfersEmptyStats')?.innerHTML || '';
return ready && (
(card && !card.hidden) ||
(title && title.textContent.includes('期末')) ||
emptyHtml.includes('期末')
);
}""",
timeout=15000,
)
fatal = [e for e in errors if "openModal is not defined" in e
or "Cannot read properties of null" in e]
self.assertEqual([], fatal, fatal)
finally:
browser.close()
def test_01_company_gap_notice_then_admin_approve(self) -> None:
with sync_playwright() as p:
browser, page, errors = self._new_page(p)
try:
self._login(
page,
portal="company",
username="cashier-a",
password=CASHIER_PASSWORD,
)
# 首屏即可见断档提醒,无需手动刷新
page.wait_for_selector(
"#companyCoverageNotice",
state="visible",
timeout=10000,
)
body = page.locator("#companyCoverageBody").inner_text()
self.assertTrue(body.strip())
self.assertNotIn("0002", body)
page.click("#openAttestationFromWorkspace")
page.wait_for_selector("#attestationDialog.open", timeout=5000)
page.fill("#att-reason", "节假日账户无资金往来")
with page.expect_request(
lambda req: req.method == "POST"
and req.url.endswith("/api/company/no-business-attestations")
) as att_req:
page.click('#attestationForm button[type="submit"]')
self.assertTrue(att_req.value.post_data)
page.wait_for_function(
"""() => !document.getElementById('attestationDialog')?.classList.contains('open')""",
timeout=8000,
)
self._login(
page,
portal="admin",
username="group-admin",
password=ADMIN_PASSWORD,
)
page.click('a[data-view="audit"]')
page.wait_for_selector(
'button[data-audit-action="approve-attestation"]',
timeout=10000,
)
with page.expect_request(
lambda req: req.method == "POST"
and "/no-business-attestations/" in req.url
and req.url.endswith("/review")
):
page.click('button[data-audit-action="approve-attestation"]')
page.wait_for_selector("#reasonDialog.open", timeout=5000)
page.fill("#reasonInput", "审核通过说明")
page.click("#reasonSubmit")
page.wait_for_function(
"""() => !document.querySelector(
'button[data-audit-action=\"approve-attestation\"]'
)""",
timeout=10000,
)
fatal = [e for e in errors if "openModal is not defined" in e]
self.assertEqual([], fatal, fatal)
finally:
browser.close()
if __name__ == "__main__":
unittest.main()