From fe6b4e59ec706be96e2116790be932bc4626299c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Thu, 27 Aug 2026 17:29:07 +0000 Subject: [PATCH] =?UTF-8?q?HEL-203:=20=E4=BF=AE=E5=A4=8D=E5=8E=9F=E5=9B=A0?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E4=BD=9C=E7=94=A8=E5=9F=9F=E3=80=81=E9=A6=96?= =?UTF-8?q?=E5=B1=8F=E6=96=AD=E6=A1=A3=E4=B8=8E=E8=A1=A8=E5=8D=95=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 openModal/closeModal 提升为顶层唯一定义,公司端启动即加载断档提醒, 并在 await 前提取表单引用;补充 Playwright 两端冒烟与源码契约测试。 Co-authored-by: Cursor Co-authored-by: multica-agent --- .gitignore | 1 + tests/test_company_confirm_status_color.py | 2 +- tests/test_company_transfers_page.py | 4 +- tests/test_hel203_browser_smoke.py | 518 +++++++++++++++++++++ web/admin.html | 2 +- web/app.js | 23 +- web/company.html | 2 +- 7 files changed, 537 insertions(+), 15 deletions(-) create mode 100644 tests/test_hel203_browser_smoke.py diff --git a/.gitignore b/.gitignore index e06217b..a1fc478 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ __pycache__/ *.py[cod] .venv/ +.chrome-libs/ .tmp-*/ # 运行时数据与真实银行文件一律不进仓库(样本仅限流水模板/中已脱敏的六份) diff --git a/tests/test_company_confirm_status_color.py b/tests/test_company_confirm_status_color.py index bfc4628..2665341 100644 --- a/tests/test_company_confirm_status_color.py +++ b/tests/test_company_confirm_status_color.py @@ -50,7 +50,7 @@ class ConfirmStatusSourceContractTests(unittest.TestCase): self.assertIn('id="workspacePendingStatus"', html) self.assertIn('id="workspaceFlowSub"', html) self.assertIn('data-view-link="reconcile"', html) - self.assertIn("app.js?v=12", html) + self.assertIn("app.js?v=13", html) # 静态初值仍为进行中(黄),由 JS 在 pending=0 时切 done self.assertRegex(html, r'class="flow-step doing"[^>]*data-view-link="reconcile"') diff --git a/tests/test_company_transfers_page.py b/tests/test_company_transfers_page.py index cdf9538..40562f0 100644 --- a/tests/test_company_transfers_page.py +++ b/tests/test_company_transfers_page.py @@ -32,7 +32,7 @@ class TransfersPageSourceContractTests(unittest.TestCase): self.assertIn("期间净变动", html) self.assertNotIn("本公司往来合计", html) self.assertIn("design-system.css?v=6", html) - self.assertIn("app.js?v=12", html) + self.assertIn("app.js?v=13", html) # 侧栏顺序:流水管理 → 转账往来 → 往来确认 flows = html.index('data-view="flows"') transfers = html.index('data-view="transfers"') @@ -113,7 +113,7 @@ class TransfersPageLayoutSmokeTests(unittest.TestCase): for width in (360, 820, 1440): page.set_viewport_size({"width": width, "height": 900}) page.set_content( - html.replace('src="app.js?v=12"', 'src=""'), + html.replace('src="app.js?v=13"', 'src=""'), base_url=self.base, ) page.evaluate( diff --git a/tests/test_hel203_browser_smoke.py b/tests/test_hel203_browser_smoke.py new file mode 100644 index 0000000..4ac8145 --- /dev/null +++ b/tests/test_hel203_browser_smoke.py @@ -0,0 +1,518 @@ +"""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): + """不依赖浏览器:锁住 N1~N3 的源码契约。""" + + 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() diff --git a/web/admin.html b/web/admin.html index 94a15ca..412c0b1 100644 --- a/web/admin.html +++ b/web/admin.html @@ -962,6 +962,6 @@
- + diff --git a/web/app.js b/web/app.js index 0adf554..39c4036 100644 --- a/web/app.js +++ b/web/app.js @@ -1175,6 +1175,9 @@ function humanizeChangeValue(value) { return String(value); } +function openModal(id) { $("#" + id)?.classList.add("open"); } +function closeModal(id) { $("#" + id)?.classList.remove("open"); } + function askReason({ title, subtitle, confirmLabel } = {}) { return new Promise((resolve) => { const dialog = $("#reasonDialog"); @@ -1818,8 +1821,6 @@ function initAdmin() { })); $("#auditCompany")?.addEventListener("change", filterAuditRows); - function openModal(id) { $("#" + id)?.classList.add("open"); } - function closeModal(id) { $("#" + id)?.classList.remove("open"); } document.querySelectorAll("[data-close]").forEach((el) => el.addEventListener("click", () => closeModal(el.dataset.close))); document.querySelectorAll(".modal-backdrop").forEach((bd) => bd.addEventListener("click", (e) => { if (e.target === bd) bd.classList.remove("open"); })); document.addEventListener("keydown", (e) => { if (e.key === "Escape") document.querySelectorAll(".modal-backdrop.open").forEach((m) => m.classList.remove("open")); }); @@ -2003,7 +2004,8 @@ function initAdmin() { $("#openCompanyDialog")?.addEventListener("click", () => openModal("companyDialog")); $("#companyForm")?.addEventListener("submit", async (event) => { event.preventDefault(); - const data = new FormData(event.currentTarget); + const form = event.currentTarget; + const data = new FormData(form); const companyName = String(data.get("companyName") || "").trim(); const loginName = String(data.get("loginName") || "").trim(); const createUser = data.get("createUser") !== null; @@ -2031,7 +2033,7 @@ function initAdmin() { } const accountCreated = Boolean(result.username); closeModal("companyDialog"); - event.currentTarget.reset(); + form.reset(); await loadAdminCompanies(); showToast( accountCreated ? "公司与账号已创建" : "公司已创建", @@ -2246,7 +2248,8 @@ function initAdmin() { $$("[data-close-opening]").forEach((button) => button.addEventListener("click", () => closeModal("openingDialog"))); $("#openingForm")?.addEventListener("submit", async (event) => { event.preventDefault(); - const data = new FormData(event.currentTarget); + const form = event.currentTarget; + const data = new FormData(form); const fromName = data.get("from"); const toName = data.get("to"); if (fromName === toName) { @@ -2280,7 +2283,7 @@ function initAdmin() { return; } closeModal("openingDialog"); - event.currentTarget.reset(); + form.reset(); await loadOpeningBalances(); await loadCalculationChanges(); await loadCalculationSettings(); @@ -3976,8 +3979,6 @@ function initCompany() { loadCompanyAccounts(); loadImportBatches(); - function openModal(id) { $("#" + id)?.classList.add("open"); } - function closeModal(id) { $("#" + id)?.classList.remove("open"); } $$("[data-close]").forEach((el) => el.addEventListener("click", () => closeModal(el.dataset.close))); $$(".modal-backdrop").forEach((bd) => bd.addEventListener("click", (e) => { if (e.target === bd) bd.classList.remove("open"); })); document.addEventListener("keydown", (e) => { if (e.key === "Escape") $$(".modal-backdrop.open").forEach((m) => m.classList.remove("open")); }); @@ -4115,6 +4116,7 @@ function initCompany() { initReconcile(); (async () => { await loadCompanyWorkspace(); + await loadCompanyCoverageGaps(); await renderReconcileMatchStack(); })(); @@ -4133,7 +4135,8 @@ function initCompany() { }); $("#accountForm")?.addEventListener("submit", async (event) => { event.preventDefault(); - const data = new FormData(event.currentTarget); + const form = event.currentTarget; + const data = new FormData(form); const response = await fetch("/api/company/accounts", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -4154,7 +4157,7 @@ function initCompany() { return; } closeModal("accountDialog"); - event.currentTarget.reset(); + form.reset(); await loadCompanyAccounts(); showToast("银行账户已提交登记", "复核通过前不能上传流水,也不参与账户识别和覆盖计算", "success"); }); diff --git a/web/company.html b/web/company.html index 8ea3479..2c8ae2c 100644 --- a/web/company.html +++ b/web/company.html @@ -1108,6 +1108,6 @@
- +