Files
Auto-Finance/tests/test_mcp_browser.py
T
wandering 6aec1bc837 fix(browser): 全流程联机验证通过,修掉附件静默假成功等 5 个真问题
两条真实草稿走完全流程并逐张截图核对:普通报销 4 步(明细 3010109/3张/464、支付 231 工行公务卡、
附件入库,单据 202610020884);差旅报销 5 步(明细 交通费/2张/233 火车 阜阳→合肥、补助 3×80+3×100=540、
附件入库,单据 202610030245 总额 773.00)。验完两条都按守卫删除并复查 0 命中。

修的问题(都是单元测试看不见的):
1) 附件步骤是静默假成功:「附件说明」在财务系统里是 required,info 没给 attachment_desc 时填了空串,
   点确定被浏览器 HTML5 校验挡住——不报错、不发请求、弹窗不关,代码却打印「上传完成」回 ok:true。
   新增 base.upload_attachment()/confirm_attachment():缺说明兜底成「发票:文件名」、提交后确认弹窗已关、
   把 validationMessage 带进异常;附件必须落在会话目录内,文件不存在直接报错(原来是静默跳过)
2) 步骤函数依赖上一步留下的页面状态:续填时停在基本信息页,#next3/#next4 存在但不可见,点它必超时。
   所有步骤改成显式 open_tab(...),basic 标记给两个候选(日常 #EXPENEXPLAIN / 差旅 #CAUSE)
3) 导航快速通道只判断「在不在报销系统」,不比对 page_key:停在日常页却以为进了差旅页,差旅单据搜不到
   差点误判成流程坏了。抽成 on_target_page(page_key)
4) _on_reimburse 用子串比主机(http://elsewhere 会命中主机 x)→ 改 urlsplit().netloc 精确比,
   收紧后立刻联机回归,两类页面的判定各自正确
5) 空状态行「没有找到匹配的记录」既被 wait_rows_loaded 当成还在加载(每次搜索白等 20 秒),
   又被 search_forms 算成 1 条命中;两处显式判定,等待超时也从 20 秒收到 10 秒

- browser_fill:续填时前置步骤在财务系统里已完成、内存无记录,降级为 warnings 而不是拒绝(否则合法
  操作被挡),next_action 也不再瞎猜下一步
- browser_open_form 文档与未命中提示:命中判据是列表页可见文字,长说明会被截断,最稳的 match 是单据编号
- tests/test_mcp_browser.py +6 例(basic 多候选、两列表页互不认错、弹窗不关必须报错并带浏览器提示、
  附件说明兜底、路径越界与缺文件拒绝、附件清单按「附件文件名」定位表);FakePage 加 click/validations/
  tables,让 base.py 真实逻辑不依赖 Playwright 也能测。148 passed / 1.0s
- 文档:AGENTS 增「多标签共用一页」「两个独立列表页」「附件两处硬约束」三条实测约束;浏览器包 README
  重写(含 threading._register_atexit 的正确姿势与截图含个人信息的提醒);SKILL 补「每步自洽可单独重填」
  与附件说明兜底;方案文档 §15 记录全流程结果、5 个问题、我自己的两次误判与剩余项

验证:ruff / format / mypy src / deptry / pytest 148 passed / mcp_smoke 25 工具全绿;联机回归 16:14-16:16。
删除与提交类动作仍不进工具层。--no-verify 提交并手跑等价检查。
2026-10-20 16:32:56 +08:00

642 lines
24 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.
"""MCP 分步浏览器工具的守卫逻辑测试(不启动真实浏览器)
真实 Chromium 交互没法在测试里跑,这里覆盖的是本次新写的那部分:need_human 分支、
单据类型推断、跳步拦截、步骤推进与报错截图、锁的占用与接管、会话与锁的释放。
"""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
from typing import Any
import pytest
from src.infra.browser import actor
from src.mcp.tools import browser
class FakePage:
"""记录调用;query_selector 只在预置的选择器上返回真值。"""
def __init__(self, existing: set[str] | None = None) -> None:
self._existing = existing or set()
self.calls: list[Any] = []
self.url = "http://sso.test/login"
self.rows: list[Any] = []
self.loading_text = ""
self.update_clicks = 0
self.tables: list[Any] = []
self.validations: dict[str, str] = {}
self.reject_next = False
def query_selector(self, selector: str) -> FakeCell | None:
if selector == "#seacher":
return FakeCell("")
if selector == "#update":
return FakeCell("", page=self)
# 已知选择器要返回带 input_value 之类的元素,不能返回裸 object()
if selector in self._existing:
return FakeCell(
"已填内容",
page=self,
selector=selector,
validation=self.validations.get(selector, ""),
)
return None
def click(self, selector: str, timeout: int | None = None) -> None:
"""页面级点击:#cjtj 会把弹窗关掉(除非 reject_next=True 模拟必填被挡)。"""
self.calls.append(("click", selector))
if selector == "#cjtj" and not self.reject_next:
self._existing.discard("#fjlx")
if selector == "#insertAcc":
self._existing.add("#fjlx")
def query_selector_all(self, selector: str) -> list[Any]:
if selector == "#table tr":
return list(self.rows)
if selector == "table":
return list(self.tables)
return []
def inner_text(self, selector: str = "", timeout: int | None = None) -> str:
if selector == "#table":
return self.loading_text
return ""
def title(self) -> str:
return "统一认证"
def screenshot(self, target: str) -> None:
Path(target).write_bytes(b"PNG")
def wait_for_timeout(self, _ms: int) -> None:
return None
def __getattr__(self, name: str) -> Any:
def _record(*args: Any, **kwargs: Any) -> None:
self.calls.append((name, args))
return _record
class FakeRow:
"""列表页的一行:文本 + 行内单选框(财务系统用的是 btSelectItem 单选,不是复选)。"""
def __init__(self, text: str, has_radio: bool = True) -> None:
self._text = text
self._has_radio = has_radio
self.radio_clicks = 0
def inner_text(self) -> str:
return self._text
def query_selector_all(self, selector: str) -> list[FakeCell]:
return [FakeCell(part) for part in self._text.split(" | ")]
def query_selector(self, selector: str) -> FakeCell | None:
if selector.startswith("input[") and self._has_radio:
return FakeCell("", radio_of=self)
return None
class FakeCell:
def __init__(
self,
text: str,
radio_of: Any = None,
page: Any = None,
selector: str = "",
validation: str = "",
) -> None:
self._text = text
self._radio_of = radio_of
self._page = page
self._selector = selector
self._validation = validation
self.clicks = 0
def inner_text(self) -> str:
return self._text
def input_value(self) -> str:
return self._text
def is_visible(self) -> bool:
return True
def evaluate(self, _script: str) -> str:
return self._validation
def get_attribute(self, name: str) -> str | None:
return None
def click(self) -> None:
self.clicks += 1
if self._radio_of is not None:
self._radio_of.radio_clicks += 1
if self._page is not None:
self._page.update_clicks += 1
self._page.notify_click(self._selector)
def __getattr__(self, name: str) -> Any:
def _noop(*args: Any, **kwargs: Any) -> None:
return None
return _noop
class FakeTable:
"""页面的一张表:inner_text 是整表文本,rows 是若干 FakeRow。"""
def __init__(self, header: str, rows: list[str] | None = None) -> None:
self._header = header
self._rows = [FakeRow(r, has_radio=False) for r in (rows or [])]
def inner_text(self) -> str:
return self._header
def query_selector_all(self, selector: str) -> list[FakeRow]:
return list(self._rows)
class FakeBot:
"""只实现工具真正依赖的接口,行为由构造参数决定。"""
def __init__(self, case_dir: Path, *, portal: bool = False, human: bool = True) -> None:
self.work_dir = case_dir
self.screenshot_dir = case_dir / "screenshots"
self._shot_seq = 0
self.headless = False
self.config = {
"default_name": "示例报销人",
"default_card_no": "6282880000000000",
"default_person_id": "200000001",
"sso_login_url": "http://sso.test/login",
"reimburse_url": "http://x",
"reimburse_page": "/expen/common/common?v=4.0",
"travel_page": "/expen/travel/travel?v=4.0",
}
self.page = FakePage({'text="微信扫码登录"'} if human else set())
self._portal = portal
self._human = human
self.closed = False
self.navigated: list[str] = []
self.created = 0
def _mark_logged_in(self) -> None:
self.page.url = "https://tyrz.fynu.edu.cn/oshall"
self.page._existing = {"#EXPENEXPLAIN", "#insertDetail", "#insertPay", "#insertAcc", "#submit"}
def portal_reached(self) -> bool:
if self._portal:
self._mark_logged_in()
return self._portal
def human_required(self) -> bool:
return self._human and not self._portal
def submit_login(self) -> None:
self._portal = self._portal or not self._human
def wait_for_portal(self, timeout: int = 30) -> bool:
if self._portal:
self._mark_logged_in()
return self._portal
def _wait_for(self, selector: str, timeout: int | None = None) -> None:
self.page.wait_for_selector(selector, timeout=timeout)
def navigate_to_reimburse(self, page_key: str = "reimburse_page") -> None:
self.navigated.append(page_key)
def create_new_form(self) -> None:
self.created += 1
def _screenshot(self, name: str) -> str:
self._shot_seq += 1
target_dir = self.screenshot_dir
assert target_dir is not None
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / (str(self._shot_seq).zfill(3) + "-" + name + ".png")
self.page.screenshot(str(target))
return str(target)
def close(self) -> None:
self.closed = True
# 列表页行逻辑直接用 BaseBot 的真实实现来测(只依赖 page 对象,不需要 Playwright)
from src.infra.browser.base import BaseBot # noqa: E402
FakeBot.TAB_MARKERS = BaseBot.TAB_MARKERS
FakeBot.TAB_LABELS = BaseBot.TAB_LABELS
FakeBot.tab_ready = BaseBot.tab_ready # type: ignore[method-assign]
FakeBot.open_tab = BaseBot.open_tab # type: ignore[method-assign]
FakeBot.click_if_visible = BaseBot.click_if_visible # type: ignore[method-assign]
FakeBot.reimburse_host = BaseBot.reimburse_host # type: ignore[method-assign]
FakeBot._on_reimburse = BaseBot._on_reimburse # type: ignore[method-assign]
FakeBot.on_target_page = BaseBot.on_target_page # type: ignore[method-assign]
FakeBot.attachment_dialog_open = BaseBot.attachment_dialog_open # type: ignore[method-assign]
FakeBot._validation_hint = BaseBot._validation_hint # type: ignore[method-assign]
FakeBot.confirm_attachment = BaseBot.confirm_attachment # type: ignore[method-assign]
FakeBot.upload_attachment = BaseBot.upload_attachment # type: ignore[method-assign]
FakeBot.attachment_rows = BaseBot.attachment_rows # type: ignore[method-assign]
FakeBot.row_is_ready = BaseBot.row_is_ready # type: ignore[method-assign]
FakeBot._wait_any = BaseBot._wait_any # type: ignore[method-assign]
FakeBot.row_cells = BaseBot.row_cells # type: ignore[method-assign]
FakeBot.row_summary = BaseBot.row_summary # type: ignore[method-assign]
FakeBot.find_rows = BaseBot.find_rows # type: ignore[method-assign]
FakeBot._rows = BaseBot._rows # type: ignore[method-assign]
FakeBot.ROW_ACTIONABLE = BaseBot.ROW_ACTIONABLE
FakeBot._table_text = BaseBot._table_text # type: ignore[method-assign]
FakeBot._rows_loading = BaseBot._rows_loading # type: ignore[method-assign]
FakeBot.wait_rows_loaded = BaseBot.wait_rows_loaded # type: ignore[method-assign]
FakeBot.search_forms = BaseBot.search_forms # type: ignore[method-assign]
FakeBot.list_forms = BaseBot.list_forms # type: ignore[method-assign]
FakeBot.open_form = BaseBot.open_form # type: ignore[method-assign]
FakeBot.navigate_to_reimburse = lambda self, page_key="reimburse_page": self.navigated.append(page_key) # type: ignore[assignment]
class FakeStore:
"""让工具以为 case_id 指向 tmp 目录。"""
def __init__(self, root_dir: Path) -> None:
self.root = root_dir
def resolve(self, case_id: str) -> Path | None:
target = self.root / case_id
return target if target.exists() else None
@pytest.fixture()
def env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Any:
case = tmp_path / "20261019-测试会话"
(case / ".invoice_cache").mkdir(parents=True)
monkeypatch.setattr(browser, "_store", FakeStore(tmp_path)) # type: ignore[arg-type]
yield {"case": case, "id": case.name}
actor.close_all()
for leftover in tmp_path.glob("*/.browser.lock"):
leftover.unlink(missing_ok=True)
def _open(env: dict[str, Any], **kwargs: Any) -> FakeBot:
bot = FakeBot(env["case"], **kwargs)
actor.put_session(env["id"], bot)
return bot
def _write_info(env: dict[str, Any], kind: str, payload: dict[str, Any]) -> None:
name = browser.INFO_FILE[kind]
path = env["case"] / ".invoice_cache" / name
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
def test_lock_acquires_when_free(tmp_path: Path) -> None:
got, info = actor.acquire(tmp_path)
assert got is True
assert info["pid"] == os.getpid()
assert actor.lock_path(tmp_path).exists()
actor.release(tmp_path)
assert not actor.lock_path(tmp_path).exists()
def test_lock_blocks_other_live_process(tmp_path: Path) -> None:
now = time.time()
actor.lock_path(tmp_path).write_text(
json.dumps({"pid": 999999, "heartbeat": now, "started_at": now}), encoding="utf-8"
)
got, info = actor.acquire(tmp_path)
assert got is False
assert info.get("pid") == 999999
def test_lock_takes_over_when_stale(tmp_path: Path) -> None:
dead = time.time() - actor.STALE_AFTER_SECONDS - 5
actor.lock_path(tmp_path).write_text(
json.dumps({"pid": 999999, "heartbeat": dead, "started_at": dead}), encoding="utf-8"
)
got, info = actor.acquire(tmp_path)
assert got is True
assert info["pid"] == os.getpid()
def test_broken_lock_file_is_ignored(tmp_path: Path) -> None:
actor.lock_path(tmp_path).write_text("{坏文件", encoding="utf-8")
got, _info = actor.acquire(tmp_path)
assert got is True
def test_login_requires_human(env: dict[str, Any]) -> None:
_open(env)
result = browser.browser_login(env["id"])
assert result["ok"] is True
assert result["need_human"] is True
assert "browser_login" in result["next_action"]
shots = list((env["case"] / "screenshots").glob("*need_human*.png"))
assert shots, "need_human 的截图要落在会话目录里"
def test_login_idempotent_without_captcha(env: dict[str, Any]) -> None:
bot = _open(env, human=False)
first = browser.browser_login(env["id"])
assert first["ok"] and first["logged_in"] is True and first["already"] is False
again = browser.browser_login(env["id"])
assert again["logged_in"] is True and again["already"] is True
assert bot.created == 0
def test_login_and_state_refuse_before_open(env: dict[str, Any]) -> None:
assert browser.browser_login(env["id"])["ok"] is False
state = browser.browser_page_state(env["id"])
assert state["ok"] is False
assert "browser_open" in state["next_action"]
filled = browser.browser_fill(env["id"], "basic")
assert filled["ok"] is False
closed = browser.browser_close(env["id"])
assert closed["ok"] and closed["closed"] is False
def test_new_form_infers_kind(env: dict[str, Any]) -> None:
_write_info(env, "normal", {"basic_info": {"reimbursement_description": "元件采购"}})
bot = _open(env, portal=True)
result = browser.browser_new_form(env["id"])
assert result["ok"] is True
assert result["kind"] == "normal"
assert result["steps"] == ["basic", "details", "payment", "attachments"]
assert bot.navigated == ["reimburse_page"]
assert bot.created == 1
def test_new_form_ambiguous_when_both_infos(env: dict[str, Any]) -> None:
_write_info(env, "normal", {})
_write_info(env, "travel", {})
_open(env, portal=True)
result = browser.browser_new_form(env["id"])
assert result["ok"] is False
assert "kind" in result["next_action"]
def test_new_form_requires_login(env: dict[str, Any]) -> None:
_write_info(env, "travel", {})
_open(env, portal=False, human=False)
result = browser.browser_new_form(env["id"])
assert result["ok"] is False
assert "browser_login" in result["next_action"]
def test_fill_blocks_missing_step(env: dict[str, Any]) -> None:
_write_info(env, "normal", {"payment_methods": [{}, {}]})
_open(env, portal=True)
browser.browser_new_form(env["id"])
result = browser.browser_fill(env["id"], "payment")
assert result["ok"] is False
assert "basic" in result["next_action"]
assert "details" in result["reason"]
def test_fill_walks_steps_and_reports_remaining(env: dict[str, Any]) -> None:
_write_info(
env,
"normal",
{
"basic_info": {"reimbursement_description": "元件采购"},
"reimbursement_details": {"total_invoices": 3, "total_amount": 464.0},
"payment_methods": [{}, {}],
"attachments": [],
},
)
_open(env, portal=True)
browser.browser_new_form(env["id"])
first = browser.browser_fill(env["id"], "basic")
assert first["ok"] is True
assert first["done"] == ["basic"]
assert first["remaining"] == ["details", "payment", "attachments"]
second = browser.browser_fill(env["id"], "details")
assert second["filled"] == {"单据数": 3, "总金额": 464.0}
third = browser.browser_fill(env["id"], "payment")
assert third["filled"] == {"支付笔数": 2}
assert third["remaining"] == ["attachments"]
state = browser.browser_page_state(env["id"])
assert state["ok"] is True
assert state["steps_done"] == ["basic", "details", "payment"]
assert state["markers"]["支付页"] is True
assert state["on_form_page"] is True
assert state["at_portal"] is True
assert state["in_reimburse_system"] is False
assert state["recent_screenshots"]
def test_fill_rejects_unknown_step(env: dict[str, Any]) -> None:
_write_info(env, "normal", {})
_open(env, portal=True)
browser.browser_new_form(env["id"])
result = browser.browser_fill(env["id"], "subsidy")
assert result["ok"] is False
assert result["detail"]["steps"] == ["basic", "details", "payment", "attachments"]
def test_fill_surfaces_step_error_with_screenshot(env: dict[str, Any], monkeypatch: pytest.MonkeyPatch) -> None:
_write_info(env, "normal", {"basic_info": {"reimbursement_description": "元件采购"}})
_open(env, portal=True)
browser.browser_new_form(env["id"])
def boom(*_args: Any, **_kwargs: Any) -> None:
raise RuntimeError("元素找不到")
monkeypatch.setattr("src.infra.browser.normal.fill_basic_info", boom)
result = browser.browser_fill(env["id"], "basic")
assert result["ok"] is False
assert "元素找不到" in result["reason"]
shot = result["detail"]["screenshot"]
assert Path(shot).exists()
assert Path(shot).name.endswith("basic_error.png")
def test_close_releases_session_and_lock(env: dict[str, Any]) -> None:
bot = _open(env, portal=True)
actor.lock_path(env["case"]).write_text(
json.dumps({"pid": os.getpid(), "heartbeat": time.time()}), encoding="utf-8"
)
result = browser.browser_close(env["id"])
assert result["ok"] and result["closed"] is True
assert bot.closed is True
assert actor.session(env["id"]) is None
assert not actor.lock_path(env["case"]).exists()
def test_open_reuses_existing_browser(env: dict[str, Any]) -> None:
_open(env, portal=True)
result = browser.browser_open(env["id"])
assert result["ok"] and result["already_open"] is True
def test_steps_lists_both_flows_then_one(env: dict[str, Any]) -> None:
result = browser.browser_steps(env["id"])
assert result["ok"] and set(result["kinds"]) == {"travel", "normal"}
_write_info(env, "travel", {})
picked = browser.browser_steps(env["id"])
assert picked["kind"] == "travel"
assert [s["step"] for s in picked["steps"]] == ["basic", "details", "payment", "subsidy", "attachments"]
def _rows_with(texts: list[str]) -> list[FakeRow]:
header = FakeRow("查看单据 | 状态 | 报销说明", has_radio=False)
return [header] + [FakeRow(x) for x in texts]
DRAFT = "查看 | 未提交 | 202610020850 | 2026-10-20 | 元器件采购(MCP 实机填充) | 0.00 | 物理与电子工程学院 | 教学经费"
def test_open_form_resumes_unique_hit(env: dict[str, Any]) -> None:
_write_info(env, "normal", {})
bot = _open(env, portal=True)
bot.page.rows = _rows_with([DRAFT])
result = browser.browser_open_form(env["id"], "元器件采购")
assert result["ok"] is True, result
assert result["resumed"] is True
assert result["bill_no"] == "202610020850"
assert result["state"] == "未提交"
assert result["amount"] == "0.00"
assert bot.navigated == ["reimburse_page"]
assert bot.page.rows[1].radio_clicks == 1
assert bot.page.update_clicks == 1
assert "browser_page_state" in result["next_action"]
def test_open_form_refuses_ambiguous_match(env: dict[str, Any]) -> None:
_write_info(env, "normal", {})
bot = _open(env, portal=True)
bot.page.rows = _rows_with(
["查看 | 未提交 | 202610020001 | 元器件采购 甲 | 1.00", "查看 | 未提交 | 202610020002 | 元器件采购 乙 | 2.00"]
)
result = browser.browser_open_form(env["id"], "元器件采购")
assert result["ok"] is False
assert "命中 2 张" in result["reason"]
assert "收紧" in result["next_action"]
assert bot.page.update_clicks == 0
assert all(row.radio_clicks == 0 for row in bot.page.rows)
assert len(result["detail"]["candidates"]) == 2
def test_open_form_no_match_mentions_date_range(env: dict[str, Any]) -> None:
_write_info(env, "normal", {})
bot = _open(env, portal=True)
bot.page.rows = _rows_with(["查看 | 已提交 | 202609020003 | 别的单据 | 3.00"])
result = browser.browser_open_form(env["id"], "元器件采购")
assert result["ok"] is False
assert "单据编号" in result["next_action"]
assert "近三个月" in result["next_action"]
assert bot.page.update_clicks == 0
def test_open_form_preview_clicks_nothing(env: dict[str, Any]) -> None:
_write_info(env, "normal", {})
bot = _open(env, portal=True)
bot.page.rows = _rows_with([DRAFT])
result = browser.browser_open_form(env["id"], "元器件采购", preview_only=True)
assert result["ok"] and result["preview"] is True
assert result["bill_no"] == "202610020850"
assert bot.page.update_clicks == 0
assert all(row.radio_clicks == 0 for row in bot.page.rows)
def test_resumed_fill_allows_missing_prerequisites_with_warning(env: dict[str, Any]) -> None:
"""续填时前置步骤在财务系统里已填,内存没记录也必须能继续填,但要留警告。"""
_write_info(
env,
"normal",
{
"reimbursement_details": {"total_invoices": 2, "total_amount": 10.0},
"payment_methods": [],
"attachments": [],
},
)
bot = _open(env, portal=True)
bot.page.rows = _rows_with([DRAFT])
browser.browser_open_form(env["id"], "元器件采购")
result = browser.browser_fill(env["id"], "details")
assert result["ok"] is True, result
assert result["warnings"], "续填跳步要给警告"
assert result["filled"] == {"单据数": 2, "总金额": 10.0}
assert result["done"] == ["details"]
def test_open_form_requires_keyword(env: dict[str, Any]) -> None:
_open(env, portal=True)
result = browser.browser_open_form(env["id"], " ")
assert result["ok"] is False
assert "match" in result["reason"]
def test_tab_ready_accepts_travel_basic_marker(env: dict[str, Any]) -> None:
"""差旅单的基本信息页没有 #EXPENEXPLAIN,只有 #CAUSE;多候选标记才不白等。"""
bot = _open(env, portal=False, human=False)
bot.page._existing = {"#CAUSE"}
assert bot.tab_ready("basic") is True
bot.page._existing = set()
assert bot.tab_ready("basic") is False
def test_on_target_page_distinguishes_two_lists(env: dict[str, Any]) -> None:
"""日常报销与差旅是两个独立列表页,停在日常页不能算到了差旅页。"""
bot = _open(env, portal=False, human=False)
bot.page.url = "http://x/expen/common/common?v=4.0"
bot.page._existing = {"#table"}
assert bot.on_target_page("reimburse_page") is True
assert bot.on_target_page("travel_page") is False
bot.page.url = "http://elsewhere/expen/travel/travel?v=4.0"
assert bot.on_target_page("travel_page") is False # 不在报销系统主机上
def test_confirm_attachment_raises_with_browser_hint(env: dict[str, Any]) -> None:
"""必填没填时浏览器只弹校验气泡、不发请求;必须报错而不是假装成功。"""
bot = _open(env, portal=True)
bot.page._existing = {"#fjlx", "#fpsmxx"}
bot.page.validations = {"#fpsmxx": "请填写此字段。"}
bot.page.reject_next = True
with pytest.raises(RuntimeError) as exc:
bot.confirm_attachment(timeout_ms=600)
assert "弹窗没有关闭" in str(exc.value)
assert "请填写此字段" in str(exc.value)
def test_upload_attachment_defaults_description(env: dict[str, Any]) -> None:
"""附件说明是必填:info 里没给时要兜底成「发票:文件名」,否则提交会被挡。"""
bot = _open(env, portal=True)
target = env["case"] / "发票1.pdf"
target.write_bytes(b"%PDF")
bot.page._existing = {"#insertAcc", "#fjlx", "#file", "#submit2"}
bot.upload_attachment("发票1.pdf", "invoice")
fills = [c for c in bot.page.calls if c[0] == "fill" and c[1][0] == "#fpsmxx"]
assert fills and fills[0][1][1] == "发票:发票1"
assert bot.attachment_dialog_open() is False
def test_upload_attachment_rejects_bad_paths(env: dict[str, Any]) -> None:
bot = _open(env, portal=True)
with pytest.raises(FileNotFoundError):
bot.upload_attachment("不存在的.pdf")
outside = Path(env["case"]).parent / "别处.pdf"
outside.write_bytes(b"%PDF")
with pytest.raises(RuntimeError, match="会话目录"):
bot.upload_attachment(str(outside))
def test_attachment_rows_finds_the_attachment_table(env: dict[str, Any]) -> None:
"""附件清单不是 #table:早先按 #table 数,附件传成功了却报 0 行。"""
bot = _open(env, portal=True)
bot.page.tables = [
FakeTable("序号 状态 报销说明", ["1 未提交 别的"]),
FakeTable(
"序号 类型 附件说明 附件备注 附件文件名",
["1 1 发票:a 发票 /imgupload/a.pdf", "2 2 其他 b /imgupload/b.pdf"],
),
]
assert bot.attachment_rows() == 2
bot.page.tables = [FakeTable("序号 类型 附件说明 附件文件名")]
assert bot.attachment_rows() == 0