feat(trading): 清空购物车落步骤证据,补空车/有商品两场景测试
- clear_cart 返回清理后 cart 页最终 HTML(best-effort,抓取失败不掩盖清理结果)
- runner step 0 落 00-cart-clear.{html,meta.json} 并登记 evidence_index,
证据先于闸门判断落盘(清理未净被拦时这份现场就是排查依据);
仍是本机卫生步骤:不上报 gateway、不记 order_events
- /api/cart/clear 响应不携带 html(路由显式挑字段,与 cart_add 同风格)
- 单测:fake page 覆盖空车/有商品/HTML 抓取失败;runner 层覆盖证据落盘
与闸门拦截场景
- 真账号复验 scripts/verify_cart_clear.py:空车 removed=0/count=0;
加购 1 件后 clear removed=1/count=0,status 复核 101
This commit is contained in:
@@ -6,6 +6,8 @@ _extract_error_message)——这三者覆盖了「从商品页 HTML 抽加购
|
||||
|
||||
SiteInteractor 类的 add_to_cart / verify_cart 涉及 Playwright,不在离线测试覆盖
|
||||
范围;只在 test_worker_runner.py 里用桩站点覆盖 runner 与 site 的契约。
|
||||
clear_cart 与 _dump_debug_snapshot 用不依赖 Playwright 的 fake page 覆盖了方法
|
||||
自身逻辑(见文件尾部对应小节),真实站点行为仍由 scripts/ 真账号脚本验证。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -33,6 +35,7 @@ from app.trading.worker.site_interact import (
|
||||
OrderStatusSnapshot,
|
||||
SiteInteractor,
|
||||
_accumulate_order_list_page,
|
||||
_DELETE_BUTTON_SELECTOR,
|
||||
_extract_error_message,
|
||||
_extract_purchase_fields,
|
||||
_OrderListAccumulator,
|
||||
@@ -1214,4 +1217,160 @@ async def test_query_cart_count_unparseable_body_raises():
|
||||
await site._query_cart_count()
|
||||
|
||||
|
||||
# ---- clear_cart:空车 / 有商品两个场景的核心逻辑(fake page,不依赖 Playwright)----
|
||||
#
|
||||
# 真实站点行为(SPA 是否点一个少一个、确认 modal 是否存在)由真账号脚本验证
|
||||
# (scripts/verify_cart_empty.py、scripts/verify_cart_clear.py);这里覆盖的是
|
||||
# 方法自身的逻辑:空车立即结束、有商品时循环点「削除」直到按钮消失、最终页面
|
||||
# HTML 进返回值、HTML 抓取失败不掩盖清理结果。
|
||||
|
||||
|
||||
class _FakeDeleteButton:
|
||||
"""「削除」按钮替身:还有剩余按钮时 wait_for 成功,click 消耗一个(模拟 SPA
|
||||
点完一个就把该项从 DOM 摘掉)"""
|
||||
|
||||
def __init__(self, page: "_FakeClearCartPage"):
|
||||
self._page = page
|
||||
|
||||
async def wait_for(self, *, state: str, timeout: int) -> None:
|
||||
if self._page.delete_buttons_left <= 0:
|
||||
raise RuntimeError("模拟 Playwright 等待超时:没有删除按钮了")
|
||||
|
||||
async def click(self) -> None:
|
||||
self._page.delete_buttons_left -= 1
|
||||
|
||||
|
||||
class _FakeMissingButton:
|
||||
"""确认 modal 按钮替身:永远不出现(站点是否弹 modal 未实测,按不存在处理)"""
|
||||
|
||||
async def wait_for(self, *, state: str, timeout: int) -> None:
|
||||
raise RuntimeError("模拟 Playwright 等待超时:无确认 modal")
|
||||
|
||||
async def click(self) -> None:
|
||||
raise AssertionError("没有可见的确认按钮,不应触发点击")
|
||||
|
||||
|
||||
class _FakeLocator:
|
||||
def __init__(self, button: Any):
|
||||
self.first = button
|
||||
|
||||
|
||||
class _FakeClearCartPage:
|
||||
"""cart 页替身:实现 clear_cart 用到的全部 page 接口"""
|
||||
|
||||
def __init__(self, *, delete_buttons: int, html: str):
|
||||
self.delete_buttons_left = delete_buttons
|
||||
self._html = html
|
||||
self.closed = False
|
||||
|
||||
def locator(self, selector: str) -> _FakeLocator:
|
||||
if selector == _DELETE_BUTTON_SELECTOR:
|
||||
return _FakeLocator(_FakeDeleteButton(self))
|
||||
return _FakeLocator(_FakeMissingButton())
|
||||
|
||||
async def goto(self, url: str, *, wait_until: str, timeout: int) -> None:
|
||||
return None
|
||||
|
||||
async def wait_for_function(self, *args: Any, **kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
async def wait_for_load_state(self, *args: Any, **kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
async def wait_for_timeout(self, *args: Any, **kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
async def content(self) -> str:
|
||||
return self._html
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _FakeClearCartContext:
|
||||
"""BrowserContext 替身:new_page 返回 fake cart 页,request 回固定 count body"""
|
||||
|
||||
def __init__(self, *, page: _FakeClearCartPage, count_body: str):
|
||||
self.page = page
|
||||
self.request = _FakeCartCountRequest(count_body)
|
||||
|
||||
async def new_page(self) -> _FakeClearCartPage:
|
||||
return self.page
|
||||
|
||||
|
||||
class _FakeLoggedInAuth:
|
||||
"""AuthSession 替身:require_logged_in 直接放行"""
|
||||
|
||||
async def require_logged_in(self, site: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _build_clear_cart_site(*, delete_buttons: int, count_body: str, html: str) -> SiteInteractor:
|
||||
"""构造 clear_cart 可离线跑起来的 SiteInteractor(fake page + fake count API)"""
|
||||
site = SiteInteractor(auth_session=_FakeLoggedInAuth(), settings=None) # type: ignore[arg-type]
|
||||
site._context = _FakeClearCartContext(
|
||||
page=_FakeClearCartPage(delete_buttons=delete_buttons, html=html),
|
||||
count_body=count_body,
|
||||
) # type: ignore[assignment]
|
||||
|
||||
async def _noop_refresh() -> None:
|
||||
return None
|
||||
|
||||
site._refresh_context_if_stale = _noop_refresh # type: ignore[assignment]
|
||||
return site
|
||||
|
||||
|
||||
_EMPTY_COUNT_BODY = 'callBack({"status":"101","message":"value not found.","count":""})'
|
||||
|
||||
|
||||
async def test_clear_cart_empty_cart_returns_success_without_clicks():
|
||||
"""场景 1:购物车没有商品 —— 找不到「削除」按钮立即结束,按清理成功返回
|
||||
|
||||
空车时 count API 返回 status=101(合法空车),cart_count 必须是 0 而不是
|
||||
-1(2026-08-16 修复过的误判);removed_count=0,最终页面 HTML 照常带回。
|
||||
"""
|
||||
site = _build_clear_cart_site(
|
||||
delete_buttons=0, count_body=_EMPTY_COUNT_BODY, html="<html>empty cart</html>",
|
||||
)
|
||||
|
||||
result = await site.clear_cart()
|
||||
|
||||
assert result == {"removed_count": 0, "cart_count": 0, "html": "<html>empty cart</html>"}
|
||||
assert site._context.page.closed is True # 页面必须关,不能泄漏
|
||||
|
||||
|
||||
async def test_clear_cart_with_items_clicks_until_no_button_left():
|
||||
"""场景 2:购物车有商品 —— 循环点第一个「削除」,点一个少一个,直到按钮消失
|
||||
|
||||
每次循环都重新查 selector(避免索引漂移),3 件商品应点 3 次;清空后 count
|
||||
API 回 status=101 → cart_count=0,最终页面 HTML 进返回值。
|
||||
"""
|
||||
site = _build_clear_cart_site(
|
||||
delete_buttons=3, count_body=_EMPTY_COUNT_BODY, html="<html>cleared cart</html>",
|
||||
)
|
||||
|
||||
result = await site.clear_cart()
|
||||
|
||||
assert result["removed_count"] == 3
|
||||
assert result["cart_count"] == 0
|
||||
assert result["html"] == "<html>cleared cart</html>"
|
||||
assert site._context.page.closed is True
|
||||
|
||||
|
||||
async def test_clear_cart_html_capture_failure_keeps_clear_result():
|
||||
"""最终页面 HTML 抓取失败(如页面异常)不掩盖清理结果本身:html 记空串"""
|
||||
site = _build_clear_cart_site(
|
||||
delete_buttons=0, count_body=_EMPTY_COUNT_BODY, html="",
|
||||
)
|
||||
|
||||
async def _broken_content() -> str:
|
||||
raise RuntimeError("page already closed")
|
||||
|
||||
site._context.page.content = _broken_content # type: ignore[assignment]
|
||||
|
||||
result = await site.clear_cart()
|
||||
|
||||
assert result == {"removed_count": 0, "cart_count": 0, "html": ""}
|
||||
|
||||
|
||||
# ---- helper ----
|
||||
|
||||
Reference in New Issue
Block a user