"""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()