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:
@@ -119,7 +119,11 @@ async def cart_clear(
|
|||||||
return ApiResponse[CartClearData](
|
return ApiResponse[CartClearData](
|
||||||
success=True,
|
success=True,
|
||||||
msg="success",
|
msg="success",
|
||||||
data=CartClearData(**result),
|
# result 还带清理后的页面 html(供 worker 落证据排查用),不进 HTTP 响应
|
||||||
|
data=CartClearData(
|
||||||
|
removed_count=result["removed_count"],
|
||||||
|
cart_count=result["cart_count"],
|
||||||
|
),
|
||||||
code=0,
|
code=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -259,9 +259,9 @@ class WorkerRunner:
|
|||||||
"""站点交互的实际调度:清购物车 → 加购 → 校验 → 确认页 → 金额守卫 → 提交 → 付款
|
"""站点交互的实际调度:清购物车 → 加购 → 校验 → 确认页 → 金额守卫 → 提交 → 付款
|
||||||
|
|
||||||
每一步的顺序:动作 → 落证据 → 写本地 SQLite → 回报 gateway。
|
每一步的顺序:动作 → 落证据 → 写本地 SQLite → 回报 gateway。
|
||||||
开单前的「清购物车」是本机侧卫生步骤(step 0,见下方代码注释),不走
|
开单前的「清购物车」是本机侧卫生步骤(step 0,见下方代码注释):落本地
|
||||||
证据与上报;站点交互失败会转 _execute_with_renewal 的 except 分支上报
|
步骤证据(页面 + meta)但不上报 gateway、不记状态事件;站点交互失败会转
|
||||||
needs_human / failed。
|
_execute_with_renewal 的 except 分支上报 needs_human / failed。
|
||||||
"""
|
"""
|
||||||
await self._db.ensure_started(task.task_id, task.site, task.intent)
|
await self._db.ensure_started(task.task_id, task.site, task.intent)
|
||||||
|
|
||||||
@@ -276,11 +276,24 @@ class WorkerRunner:
|
|||||||
# 购物车,保证「一单 = 只买本单商品」的不变式——无论上一单是怎么失败的
|
# 购物车,保证「一单 = 只买本单商品」的不变式——无论上一单是怎么失败的
|
||||||
# (包括进程中途崩溃,残留都没人清),这里都从空车起步。
|
# (包括进程中途崩溃,残留都没人清),这里都从空车起步。
|
||||||
#
|
#
|
||||||
# 这条不在 gateway 上报,也不写步骤证据:它是本机侧的卫生操作,不是订单
|
# 这条不在 gateway 上报、不记 order_events:它是本机侧的卫生操作,不是订单
|
||||||
# 进度的状态迁移。清理后 count 非 0(含 count API 拿不到结果返回 -1)说明
|
# 进度的状态迁移。但清理后的页面快照与 meta 要落本地证据并登记
|
||||||
# 清理没跑干净,**不能**带着残留往下加购——有把上一单买走的真实风险,
|
# evidence_index——清理没跑干净被下面的闸门拦下转 needs_human 时,这份现场
|
||||||
# 按闸门语义拦截转 needs_human,宁可卡住等人核对,也不赌「残留不会被买走」。
|
# 就是排查依据,所以证据必须先于闸门判断落盘。清理后 count 非 0(含
|
||||||
|
# count API 拿不到结果返回 -1)说明清理没跑干净,**不能**带着残留往下
|
||||||
|
# 加购——有把上一单买走的真实风险,按闸门语义拦截转 needs_human,
|
||||||
|
# 宁可卡住等人核对,也不赌「残留不会被买走」。
|
||||||
cleared = await self._site.clear_cart()
|
cleared = await self._site.clear_cart()
|
||||||
|
evidence_ref = self._evidence.write_step(
|
||||||
|
task.task_id, 0, "cart-clear",
|
||||||
|
html=cleared.get("html") or None,
|
||||||
|
meta={
|
||||||
|
"step": "cart-clear",
|
||||||
|
"removed_count": cleared.get("removed_count"),
|
||||||
|
"cart_count": cleared.get("cart_count"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await self._db.index_evidence(task.task_id, 0, "cart-clear", evidence_ref)
|
||||||
if cleared.get("cart_count", -1) != 0:
|
if cleared.get("cart_count", -1) != 0:
|
||||||
raise OrderGuardError(
|
raise OrderGuardError(
|
||||||
"开单前清理购物车后仍未清空"
|
"开单前清理购物车后仍未清空"
|
||||||
|
|||||||
@@ -1010,8 +1010,11 @@ class SiteInteractor:
|
|||||||
- 若 SPA 把按钮渲染在 iframe 里,selector 失败需实测后调整
|
- 若 SPA 把按钮渲染在 iframe 里,selector 失败需实测后调整
|
||||||
- Rakuten cart item 卡片无 data-testid,本方法不依赖 DOM 结构定位
|
- Rakuten cart item 卡片无 data-testid,本方法不依赖 DOM 结构定位
|
||||||
|
|
||||||
返回 {removed_count, cart_count};cart_count=-1 表示末尾 count API 调用失败
|
返回 {removed_count, cart_count, html};cart_count=-1 表示末尾 count API
|
||||||
(空车属正常结果返回 0,见 _query_cart_count 的 status=101 处理)。
|
调用失败(空车属正常结果返回 0,见 _query_cart_count 的 status=101 处理)。
|
||||||
|
html 是清理结束后 cart 页的最终渲染结果,供调用方落证据排查——清理没跑干净
|
||||||
|
被 runner 闸门拦下时这份现场就是排查依据;HTTP /api/cart/clear 入口的响应
|
||||||
|
模型不携带它。
|
||||||
"""
|
"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
await self._auth_session.require_logged_in("rakuten")
|
await self._auth_session.require_logged_in("rakuten")
|
||||||
@@ -1019,6 +1022,7 @@ class SiteInteractor:
|
|||||||
|
|
||||||
page = await self._context.new_page()
|
page = await self._context.new_page()
|
||||||
removed = 0
|
removed = 0
|
||||||
|
html = ""
|
||||||
try:
|
try:
|
||||||
await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000)
|
await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000)
|
||||||
await self._wait_cart_rendered(page, label="clear_cart")
|
await self._wait_cart_rendered(page, label="clear_cart")
|
||||||
@@ -1047,6 +1051,16 @@ class SiteInteractor:
|
|||||||
"clear_cart 触发安全上限 %s,可能有删除失败或 SPA 异常",
|
"clear_cart 触发安全上限 %s,可能有删除失败或 SPA 异常",
|
||||||
_CLEAR_CART_MAX_ITER,
|
_CLEAR_CART_MAX_ITER,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 清理结束后的最终页面留给调用方落证据(worker runner step 0);
|
||||||
|
# 抓取失败只记日志,不把排查用的副作用变成新的失败源
|
||||||
|
try:
|
||||||
|
html = await page.content()
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"clear_cart:抓取最终页面 HTML 失败(不影响清理结果)",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
await page.close()
|
await page.close()
|
||||||
|
|
||||||
@@ -1058,7 +1072,7 @@ class SiteInteractor:
|
|||||||
cart_count = -1
|
cart_count = -1
|
||||||
|
|
||||||
logger.info("clear_cart 完成:removed=%s cart_count=%s", removed, cart_count)
|
logger.info("clear_cart 完成:removed=%s cart_count=%s", removed, cart_count)
|
||||||
return {"removed_count": removed, "cart_count": cart_count}
|
return {"removed_count": removed, "cart_count": cart_count, "html": html}
|
||||||
|
|
||||||
async def remove_item(self, item_id: str) -> dict:
|
async def remove_item(self, item_id: str) -> dict:
|
||||||
"""删除购物车里指定 item_id 的商品
|
"""删除购物车里指定 item_id 的商品
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""真账号验证:clear_cart 两个场景——空车返回成功 / 有商品正确清除
|
||||||
|
|
||||||
|
场景 1(空车):clear_cart 应 removed_count=0、cart_count=0(status=101 是合法
|
||||||
|
空车,不是获取失败),并带回清理后的页面 HTML(runner step 0 落证据用)。
|
||||||
|
场景 2(有商品):先加购 1 件商品,clear_cart 应 removed_count>=1、cart_count=0,
|
||||||
|
末尾再用 cart_status 独立复核确实是空车。
|
||||||
|
|
||||||
|
前置:登录态有效(.auth/ 下有 storage_state);浏览器必须非无头(站点风控要求,
|
||||||
|
会弹出真实浏览器窗口)。商品 URL 与其他探针(probe_new_card 等)一致。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
.venv/Scripts/python.exe scripts/verify_cart_clear.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from app.shared.config import get_settings # noqa: E402
|
||||||
|
from app.trading.services.auth_session import AuthSession # noqa: E402
|
||||||
|
from app.trading.worker.site_interact import SiteInteractor # noqa: E402
|
||||||
|
|
||||||
|
ITEM_URL = "https://item.rakuten.co.jp/moccasin/ds001iwrgesaaa2/"
|
||||||
|
|
||||||
|
|
||||||
|
async def run() -> int:
|
||||||
|
settings = get_settings()
|
||||||
|
auth = AuthSession(settings)
|
||||||
|
await auth.start()
|
||||||
|
site = SiteInteractor(auth_session=auth, settings=settings)
|
||||||
|
await site.start()
|
||||||
|
try:
|
||||||
|
# 前置:先清一次,保证从空车起步(顺便清掉上次验证可能残留的商品)
|
||||||
|
pre = await site.clear_cart()
|
||||||
|
print(f"前置 clear -> removed={pre['removed_count']} cart_count={pre['cart_count']}")
|
||||||
|
assert pre["cart_count"] == 0, f"前置清理后应为空车,实际 {pre['cart_count']}"
|
||||||
|
|
||||||
|
# 场景 1:空车 —— 应直接返回清理成功,不做任何点击
|
||||||
|
empty = await site.clear_cart()
|
||||||
|
print(
|
||||||
|
f"场景1 空车 -> removed={empty['removed_count']} "
|
||||||
|
f"cart_count={empty['cart_count']} html={len(empty['html'])}B"
|
||||||
|
)
|
||||||
|
assert empty["removed_count"] == 0, f"空车不应有点击,实际 {empty['removed_count']}"
|
||||||
|
assert empty["cart_count"] == 0, f"空车 cart_count 应为 0,实际 {empty['cart_count']}"
|
||||||
|
assert empty["html"], "空车也应带回页面 HTML(runner 落证据用)"
|
||||||
|
|
||||||
|
# 场景 2:有商品 —— 先加购 1 件,再清,应真删掉
|
||||||
|
added = await site.add_to_cart_payload(item_url=ITEM_URL, quantity=1)
|
||||||
|
print(f"加购 -> item_id={added['item_id']} cart_count={added['cart_count']}")
|
||||||
|
assert added["cart_count"] >= 1, "加购后购物车应有商品"
|
||||||
|
|
||||||
|
cleared = await site.clear_cart()
|
||||||
|
print(
|
||||||
|
f"场景2 有商品 -> removed={cleared['removed_count']} "
|
||||||
|
f"cart_count={cleared['cart_count']} html={len(cleared['html'])}B"
|
||||||
|
)
|
||||||
|
assert cleared["removed_count"] >= 1, (
|
||||||
|
f"有商品时至少点 1 次「削除」,实际 {cleared['removed_count']}"
|
||||||
|
)
|
||||||
|
assert cleared["cart_count"] == 0, f"清理后应为空车,实际 {cleared['cart_count']}"
|
||||||
|
assert cleared["html"], "清理后应带回页面 HTML"
|
||||||
|
|
||||||
|
# 复核:独立的 status 查询确认确实是空车(不只信 clear 末尾的一次校验)
|
||||||
|
status = await site.cart_status()
|
||||||
|
print(f"复核 status -> {status}")
|
||||||
|
assert status["count"] == 0 and status["raw_status"] == "101", (
|
||||||
|
f"复核应为空车(count=0, raw_status=101),实际 {status}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n验证通过:空车正确返回成功;有商品时正确清除并复核为空车")
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
await site.close()
|
||||||
|
await auth.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(asyncio.run(run()))
|
||||||
@@ -6,6 +6,8 @@ _extract_error_message)——这三者覆盖了「从商品页 HTML 抽加购
|
|||||||
|
|
||||||
SiteInteractor 类的 add_to_cart / verify_cart 涉及 Playwright,不在离线测试覆盖
|
SiteInteractor 类的 add_to_cart / verify_cart 涉及 Playwright,不在离线测试覆盖
|
||||||
范围;只在 test_worker_runner.py 里用桩站点覆盖 runner 与 site 的契约。
|
范围;只在 test_worker_runner.py 里用桩站点覆盖 runner 与 site 的契约。
|
||||||
|
clear_cart 与 _dump_debug_snapshot 用不依赖 Playwright 的 fake page 覆盖了方法
|
||||||
|
自身逻辑(见文件尾部对应小节),真实站点行为仍由 scripts/ 真账号脚本验证。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -33,6 +35,7 @@ from app.trading.worker.site_interact import (
|
|||||||
OrderStatusSnapshot,
|
OrderStatusSnapshot,
|
||||||
SiteInteractor,
|
SiteInteractor,
|
||||||
_accumulate_order_list_page,
|
_accumulate_order_list_page,
|
||||||
|
_DELETE_BUTTON_SELECTOR,
|
||||||
_extract_error_message,
|
_extract_error_message,
|
||||||
_extract_purchase_fields,
|
_extract_purchase_fields,
|
||||||
_OrderListAccumulator,
|
_OrderListAccumulator,
|
||||||
@@ -1214,4 +1217,160 @@ async def test_query_cart_count_unparseable_body_raises():
|
|||||||
await site._query_cart_count()
|
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 ----
|
# ---- helper ----
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ evidence store / site,覆盖 §6 主循环的分支:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -559,6 +560,62 @@ async def test_execute_blocks_when_clear_count_unknown(
|
|||||||
assert "未清空" in terminal["detail"]
|
assert "未清空" in terminal["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_clear_cart_step_writes_evidence_but_no_gateway_report(
|
||||||
|
runner: WorkerRunner, evidence: EvidenceStore
|
||||||
|
):
|
||||||
|
"""step 0 落本地证据:清理后的页面 HTML 与 meta.json 在盘上,但不回报 gateway
|
||||||
|
|
||||||
|
clear_cart 返回的 html 是清理结束后的 cart 页现场,meta 记 removed_count /
|
||||||
|
cart_count;它仍是本机卫生步骤——evidence_index 可翻查,gateway 收不到
|
||||||
|
这一步的 evidence_ref。
|
||||||
|
"""
|
||||||
|
async def _clear(): # noqa: ANN001
|
||||||
|
return {"removed_count": 1, "cart_count": 0, "html": "<html>cart</html>"}
|
||||||
|
|
||||||
|
async def _noop(task): # noqa: ANN001
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _unimplemented(task): # noqa: ANN001
|
||||||
|
# 流程走到 enter_checkout 为止:step 0 的证据此时已经落盘
|
||||||
|
raise NotImplementedError("模拟:enter_checkout 未实现,到此为止")
|
||||||
|
|
||||||
|
runner._site.clear_cart = _clear # type: ignore[assignment]
|
||||||
|
runner._site.add_to_cart = _noop # type: ignore[assignment]
|
||||||
|
runner._site.verify_cart = _noop # type: ignore[assignment]
|
||||||
|
runner._site.enter_checkout = _unimplemented # type: ignore[assignment]
|
||||||
|
|
||||||
|
await runner.handle(_make_task(task_id="t1"))
|
||||||
|
|
||||||
|
step_dir = evidence.step_dir("t1")
|
||||||
|
html_text = (step_dir / "00-cart-clear.html").read_text(encoding="utf-8")
|
||||||
|
assert html_text == "<html>cart</html>"
|
||||||
|
meta = json.loads((step_dir / "00-cart-clear.meta.json").read_text(encoding="utf-8"))
|
||||||
|
assert meta == {"step": "cart-clear", "removed_count": 1, "cart_count": 0}
|
||||||
|
# 本机卫生步骤:gateway 的任何 report 都不携带 step-0 的 evidence_ref
|
||||||
|
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
|
||||||
|
assert all(r["evidence_ref"] != "t1/00-cart-clear" for r in gateway.reports)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_clear_cart_evidence_written_when_guard_blocks(
|
||||||
|
runner: WorkerRunner, evidence: EvidenceStore
|
||||||
|
):
|
||||||
|
"""闸门拦截(清理后 cart_count 非 0)时 step-0 证据同样已落盘
|
||||||
|
|
||||||
|
被拦转 needs_human 时这份现场就是排查依据——证据先于闸门判断落盘。
|
||||||
|
"""
|
||||||
|
async def _clear(): # noqa: ANN001
|
||||||
|
return {"removed_count": 2, "cart_count": 2, "html": "<html>leftover</html>"}
|
||||||
|
|
||||||
|
runner._site.clear_cart = _clear # type: ignore[assignment]
|
||||||
|
|
||||||
|
await runner.handle(_make_task(task_id="t1"))
|
||||||
|
|
||||||
|
step_dir = evidence.step_dir("t1")
|
||||||
|
assert (step_dir / "00-cart-clear.html").exists()
|
||||||
|
meta = json.loads((step_dir / "00-cart-clear.meta.json").read_text(encoding="utf-8"))
|
||||||
|
assert meta["cart_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
# ---- 证据在 report 之前落盘(§9 第 11 条)----
|
# ---- 证据在 report 之前落盘(§9 第 11 条)----
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user