feat(client): verify same-product safe exit
This commit is contained in:
@@ -28,6 +28,12 @@ _ENTRY_ACTION_DESC = "快要抢光¥12.88"
|
|||||||
_ENTRY_ACTION_BOUNDS = "[446,2166][1080,2328]"
|
_ENTRY_ACTION_BOUNDS = "[446,2166][1080,2328]"
|
||||||
_ENTRY_SIBLING = "免拼购买"
|
_ENTRY_SIBLING = "免拼购买"
|
||||||
_ENTRY_SIBLING_BOUNDS = "[688,2256][856,2305]"
|
_ENTRY_SIBLING_BOUNDS = "[688,2256][856,2305]"
|
||||||
|
_PRODUCT_TITLE_DESC = "2026年新款高档重工潮流烫钻中长款T恤显瘦宽松上衣淡人穿搭"
|
||||||
|
_PRODUCT_TITLE_FIRST_LINE = "2026年新款高档重工潮流烫钻中长款T恤显瘦宽松"
|
||||||
|
_PRODUCT_TITLE_SECOND_LINE = "上衣淡人穿搭"
|
||||||
|
_PRODUCT_TITLE_RESOURCE_ID = "com.xunmeng.pinduoduo:id/tv_title"
|
||||||
|
_PRODUCT_TITLE_BOUNDS = "[36,1571][1044,1689]"
|
||||||
|
_DANGEROUS_EXIT_ACTION_TERMS = ("提交订单", "确认订单", "立即支付", "去支付", "付款")
|
||||||
_FORBIDDEN_ENTRY_ACTION_DESC = (
|
_FORBIDDEN_ENTRY_ACTION_DESC = (
|
||||||
"购买", "下单", "付款", "订单", "单独购买", "直接拼成", "提交订单", "支付",
|
"购买", "下单", "付款", "订单", "单独购买", "直接拼成", "提交订单", "支付",
|
||||||
"先用后付", "0元下单", "0 元下单",
|
"先用后付", "0元下单", "0 元下单",
|
||||||
@@ -423,16 +429,22 @@ class SkuSelectionFlow:
|
|||||||
self._terminal = True
|
self._terminal = True
|
||||||
raise
|
raise
|
||||||
deadline = self._clock() + self._entry_timeout
|
deadline = self._clock() + self._entry_timeout
|
||||||
|
stable: tuple[object, ...] | None = None
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
self._require_foreground()
|
self._require_foreground()
|
||||||
raw = self._read_hierarchy()
|
raw = self._read_hierarchy()
|
||||||
if raw != before:
|
|
||||||
try:
|
try:
|
||||||
_classify_panel(_parse_nodes(raw))
|
projection = _product_exit_projection(_parse_nodes(raw))
|
||||||
except SkuSelectionError:
|
except SkuSelectionError:
|
||||||
|
stable = None
|
||||||
|
else:
|
||||||
|
# 用前台/版本检查夹住第二棵候选树;页面在 dump 后漂移时不能成功。
|
||||||
|
self._require_foreground()
|
||||||
|
if stable == projection:
|
||||||
self._terminal = True
|
self._terminal = True
|
||||||
return
|
return
|
||||||
|
stable = projection
|
||||||
remaining = deadline - self._clock()
|
remaining = deadline - self._clock()
|
||||||
if remaining <= 0:
|
if remaining <= 0:
|
||||||
raise SkuSelectionError("安全退出后未确认离开规格面板,未重试返回。")
|
raise SkuSelectionError("安全退出后未确认离开规格面板,未重试返回。")
|
||||||
@@ -1394,6 +1406,62 @@ def _entry_projection(node: _Node, nodes: list[_Node]) -> tuple[object, ...] | N
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _product_exit_projection(nodes: list[_Node]) -> tuple[object, ...]:
|
||||||
|
"""返回 T-104 人验同商品详情页的最小稳定投影;不读取页面价格。"""
|
||||||
|
|
||||||
|
if any(
|
||||||
|
_is_live_clickable(node)
|
||||||
|
and any(term in value for value in (node.text, node.desc) for term in _DANGEROUS_EXIT_ACTION_TERMS)
|
||||||
|
for node in nodes
|
||||||
|
):
|
||||||
|
# T-106/T-107 前只把这些结构当保守拒绝,不宣称它们是完整确认/支付页分类器。
|
||||||
|
raise SkuSelectionError("退出后出现危险动作语义,不能确认安全退出。")
|
||||||
|
|
||||||
|
entries = _eligible_entries(nodes)
|
||||||
|
if len(entries) != 1:
|
||||||
|
raise SkuSelectionError("退出后商品规格入口不唯一。")
|
||||||
|
entry = _entry_projection(entries[0], nodes)
|
||||||
|
if entry is None:
|
||||||
|
raise SkuSelectionError("退出后商品规格入口结构失效。")
|
||||||
|
|
||||||
|
titles = [
|
||||||
|
node
|
||||||
|
for node in nodes
|
||||||
|
if _exact_common(
|
||||||
|
node,
|
||||||
|
"android.view.ViewGroup",
|
||||||
|
_PRODUCT_TITLE_BOUNDS,
|
||||||
|
clickable="true",
|
||||||
|
selected="false",
|
||||||
|
scrollable="false",
|
||||||
|
)
|
||||||
|
and node.element.get("resource-id") == _PRODUCT_TITLE_RESOURCE_ID
|
||||||
|
and node.element.get("long-clickable") == "true"
|
||||||
|
and not node.text
|
||||||
|
and node.desc == _PRODUCT_TITLE_DESC
|
||||||
|
]
|
||||||
|
title = _one(titles, "退出后同商品标题正锚不唯一。")
|
||||||
|
children = _walk_direct_children(title)
|
||||||
|
expected_children = (
|
||||||
|
(_PRODUCT_TITLE_FIRST_LINE, "[36,1571][1023,1624]"),
|
||||||
|
(_PRODUCT_TITLE_SECOND_LINE, "[36,1636][306,1689]"),
|
||||||
|
)
|
||||||
|
if len(children) != len(expected_children) or any(
|
||||||
|
not _exact_readonly_text(child, text, bounds)
|
||||||
|
for child, (text, bounds) in zip(children, expected_children, strict=True)
|
||||||
|
):
|
||||||
|
raise SkuSelectionError("退出后同商品标题子结构漂移。")
|
||||||
|
|
||||||
|
return (
|
||||||
|
"product_exit_8_17_0",
|
||||||
|
entry,
|
||||||
|
_entry_node_projection(title),
|
||||||
|
tuple(_entry_node_projection(child) for child in children),
|
||||||
|
title.element.get("resource-id", ""),
|
||||||
|
title.element.get("long-clickable", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _entry_node_projection(node: _Node) -> tuple[str, ...]:
|
def _entry_node_projection(node: _Node) -> tuple[str, ...]:
|
||||||
return (
|
return (
|
||||||
node.element.tag,
|
node.element.tag,
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ class SkuSelectionRunResult:
|
|||||||
screenshot_path: Path
|
screenshot_path: Path
|
||||||
manifest_path: Path
|
manifest_path: Path
|
||||||
unit_price: str
|
unit_price: str
|
||||||
|
captured_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -589,7 +590,9 @@ class SkuSelectionRunner:
|
|||||||
stage = "screenshot_capture"
|
stage = "screenshot_capture"
|
||||||
screenshot_path = staging / "screenshot.png"
|
screenshot_path = staging / "screenshot.png"
|
||||||
try:
|
try:
|
||||||
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
|
screenshot_payload = adapter.capture_screenshot()
|
||||||
|
captured_at = datetime.now(UTC)
|
||||||
|
_save_base64_screenshot(screenshot_payload, screenshot_path)
|
||||||
_require_screenshot_size(screenshot_path)
|
_require_screenshot_size(screenshot_path)
|
||||||
except SkuSelectionRunError:
|
except SkuSelectionRunError:
|
||||||
raise
|
raise
|
||||||
@@ -610,7 +613,22 @@ class SkuSelectionRunner:
|
|||||||
|
|
||||||
stage = "publish"
|
stage = "publish"
|
||||||
manifest_path.write_text(
|
manifest_path.write_text(
|
||||||
json.dumps(_manifest(inspection, serial, link, screenshot_path, task_color, task_size, adapter), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
json.dumps(
|
||||||
|
_manifest(
|
||||||
|
inspection,
|
||||||
|
serial,
|
||||||
|
link,
|
||||||
|
screenshot_path,
|
||||||
|
task_color,
|
||||||
|
task_size,
|
||||||
|
adapter,
|
||||||
|
captured_at,
|
||||||
|
),
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
# Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。
|
# Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。
|
||||||
@@ -648,6 +666,7 @@ class SkuSelectionRunner:
|
|||||||
screenshot_path=target / "screenshot.png",
|
screenshot_path=target / "screenshot.png",
|
||||||
manifest_path=target / "manifest.json",
|
manifest_path=target / "manifest.json",
|
||||||
unit_price=EXPECTED_UNIT_PRICE,
|
unit_price=EXPECTED_UNIT_PRICE,
|
||||||
|
captured_at=captured_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -819,13 +838,14 @@ def _manifest(
|
|||||||
task_color: str,
|
task_color: str,
|
||||||
task_size: str,
|
task_size: str,
|
||||||
adapter: UiautomatorSkuPanelAdapter,
|
adapter: UiautomatorSkuPanelAdapter,
|
||||||
|
captured_at: datetime,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""仅写可审计摘要;原始 serial、节点树、页面文案和实际截图内容均不写入 manifest。"""
|
"""仅写可审计摘要;原始 serial、节点树、页面文案和实际截图内容均不写入 manifest。"""
|
||||||
|
|
||||||
option_outcomes = dict(adapter.option_rpc_outcomes)
|
option_outcomes = dict(adapter.option_rpc_outcomes)
|
||||||
return {
|
return {
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"captured_at": datetime.now(UTC).isoformat(),
|
"captured_at": captured_at.isoformat(),
|
||||||
"operation": "t103-sku-selection",
|
"operation": "t103-sku-selection",
|
||||||
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
|
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
|
||||||
"target_selection": {"color": task_color, "size": task_size},
|
"target_selection": {"color": task_color, "size": task_size},
|
||||||
@@ -860,8 +880,9 @@ def _manifest(
|
|||||||
"rpc_outcome": adapter.back_rpc_outcome,
|
"rpc_outcome": adapter.back_rpc_outcome,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"post_exit_status": "human_review_required",
|
"post_exit_status": "same_product_verified",
|
||||||
"page_identity": "human_review_required",
|
"safe_exit": "completed",
|
||||||
|
"page_identity": "same_goods_evidence_bound",
|
||||||
"channel": "wifi" if ":" in serial else "usb",
|
"channel": "wifi" if ":" in serial else "usb",
|
||||||
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||||
"device": {
|
"device": {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<hierarchy rotation="0">
|
||||||
|
<!-- T-104 同商品正锚:只保留人工确认 post-exit XML 中的标题容器及两个直接文本子节点。 -->
|
||||||
|
<node resource-id="com.xunmeng.pinduoduo:id/tv_title" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1571][1044,1689]" content-desc="2026年新款高档重工潮流烫钻中长款T恤显瘦宽松上衣淡人穿搭" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" long-clickable="true">
|
||||||
|
<node text="2026年新款高档重工潮流烫钻中长款T恤显瘦宽松" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1571][1023,1624]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||||
|
<node text="上衣淡人穿搭" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1636][306,1689]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||||
|
</node>
|
||||||
|
<!-- 同一份 post-exit XML 中的底部规格入口;只证明页面身份,不在安全退出中点击。 -->
|
||||||
|
<node content-desc="快要抢光¥12.88" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[446,2166][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||||
|
<node text="快要抢光 ¥ 12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[688,2184][1042,2253]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||||
|
<node text="免拼购买" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[688,2256][856,2305]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||||
|
</node>
|
||||||
|
</hierarchy>
|
||||||
@@ -25,6 +25,7 @@ from cmbuyer_client.pdd.sku_selection import (
|
|||||||
_action_bounds,
|
_action_bounds,
|
||||||
_classify_panel,
|
_classify_panel,
|
||||||
_parse_nodes,
|
_parse_nodes,
|
||||||
|
_product_exit_projection,
|
||||||
resolve_task_selection,
|
resolve_task_selection,
|
||||||
)
|
)
|
||||||
from cmbuyer_client.pdd.sku_selection_runner import (
|
from cmbuyer_client.pdd.sku_selection_runner import (
|
||||||
@@ -48,10 +49,12 @@ _S_FIXTURE = _FIXTURES / "sku_panel_size_s_selected_8_17_0.xml"
|
|||||||
_M_FIXTURE = _FIXTURES / "sku_panel_size_m_restored_8_17_0.xml"
|
_M_FIXTURE = _FIXTURES / "sku_panel_size_m_restored_8_17_0.xml"
|
||||||
_FIXTURE = _M_FIXTURE
|
_FIXTURE = _M_FIXTURE
|
||||||
_ENTRY_FIXTURE = Path(__file__).with_name("fixtures") / "product_entry_8_17_0.xml"
|
_ENTRY_FIXTURE = Path(__file__).with_name("fixtures") / "product_entry_8_17_0.xml"
|
||||||
|
_EXIT_FIXTURE = Path(__file__).with_name("fixtures") / "product_exit_8_17_0.xml"
|
||||||
_TARGET_URL = "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"
|
_TARGET_URL = "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"
|
||||||
_TASK_COLOR = "黑色CHA(纯棉)"
|
_TASK_COLOR = "黑色CHA(纯棉)"
|
||||||
_TASK_SIZE = "M(建议100-115)"
|
_TASK_SIZE = "M(建议100-115)"
|
||||||
_PRODUCT_PAGE = _ENTRY_FIXTURE.read_text(encoding="utf-8")
|
_PRODUCT_PAGE = _ENTRY_FIXTURE.read_text(encoding="utf-8")
|
||||||
|
_PRODUCT_EXIT = _EXIT_FIXTURE.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def _revealed_current_only() -> str:
|
def _revealed_current_only() -> str:
|
||||||
@@ -96,7 +99,7 @@ class _RawDevice:
|
|||||||
if method == "takeScreenshot":
|
if method == "takeScreenshot":
|
||||||
return self.screenshot
|
return self.screenshot
|
||||||
if method == "pressKey":
|
if method == "pressKey":
|
||||||
self.hierarchy = "<hierarchy />"
|
self.hierarchy = _PRODUCT_EXIT
|
||||||
return ""
|
return ""
|
||||||
if method == "click":
|
if method == "click":
|
||||||
if not isinstance(params, list) or len(params) != 2:
|
if not isinstance(params, list) or len(params) != 2:
|
||||||
@@ -1156,6 +1159,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
|||||||
_S_FIXTURE,
|
_S_FIXTURE,
|
||||||
_M_FIXTURE,
|
_M_FIXTURE,
|
||||||
_ENTRY_FIXTURE,
|
_ENTRY_FIXTURE,
|
||||||
|
_EXIT_FIXTURE,
|
||||||
):
|
):
|
||||||
content = fixture.read_text(encoding="utf-8")
|
content = fixture.read_text(encoding="utf-8")
|
||||||
with self.subTest(fixture=fixture.name):
|
with self.subTest(fixture=fixture.name):
|
||||||
@@ -1164,6 +1168,170 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
|||||||
self.assertNotIn(forbidden, content)
|
self.assertNotIn(forbidden, content)
|
||||||
self.assertNotIn("提交订单", content)
|
self.assertNotIn("提交订单", content)
|
||||||
|
|
||||||
|
def test_product_exit_fixture_binds_exact_same_product_and_entry(self) -> None:
|
||||||
|
projection = _product_exit_projection(_parse_nodes(_PRODUCT_EXIT))
|
||||||
|
self.assertEqual(projection[0], "product_exit_8_17_0")
|
||||||
|
|
||||||
|
root = ElementTree.fromstring(_PRODUCT_EXIT)
|
||||||
|
title = next(
|
||||||
|
node
|
||||||
|
for node in root.iter("node")
|
||||||
|
if node.get("resource-id") == "com.xunmeng.pinduoduo:id/tv_title"
|
||||||
|
)
|
||||||
|
root.append(ElementTree.fromstring(ElementTree.tostring(title, encoding="unicode")))
|
||||||
|
duplicate = ElementTree.tostring(root, encoding="unicode")
|
||||||
|
missing = _PRODUCT_EXIT.replace(
|
||||||
|
"com.xunmeng.pinduoduo:id/tv_title",
|
||||||
|
"com.xunmeng.pinduoduo:id/other",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
drifted = _PRODUCT_EXIT.replace("[36,1571][1044,1689]", "[35,1571][1044,1689]", 1)
|
||||||
|
dangerous = _PRODUCT_EXIT.replace(
|
||||||
|
"</hierarchy>",
|
||||||
|
'<node content-desc="提交订单" package="com.xunmeng.pinduoduo" '
|
||||||
|
'class="android.view.ViewGroup" bounds="[36,1800][400,1900]" clickable="true" '
|
||||||
|
'enabled="true" visible-to-user="true" selected="false" scrollable="false" /></hierarchy>',
|
||||||
|
)
|
||||||
|
system_ui = _PRODUCT_EXIT.replace("com.xunmeng.pinduoduo", "com.android.systemui")
|
||||||
|
non_pdd_overlay = _PRODUCT_EXIT.replace(
|
||||||
|
"</hierarchy>",
|
||||||
|
'<node content-desc="overlay" package="external.app" '
|
||||||
|
'class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="true" '
|
||||||
|
'enabled="true" visible-to-user="true" selected="false" scrollable="false" /></hierarchy>',
|
||||||
|
)
|
||||||
|
lock_screen = (
|
||||||
|
'<hierarchy><node content-desc="锁屏" package="com.android.systemui" '
|
||||||
|
'class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" '
|
||||||
|
'enabled="true" visible-to-user="true" /></hierarchy>'
|
||||||
|
)
|
||||||
|
|
||||||
|
for name, hierarchy in (
|
||||||
|
("empty", "<hierarchy />"),
|
||||||
|
("panel_still_open", _M_FIXTURE.read_text(encoding="utf-8")),
|
||||||
|
("system_ui", system_ui),
|
||||||
|
("non_pdd_overlay", non_pdd_overlay),
|
||||||
|
("lock_screen", lock_screen),
|
||||||
|
("other_pdd_page", _PRODUCT_PAGE),
|
||||||
|
("anchor_missing", missing),
|
||||||
|
("anchor_duplicate", duplicate),
|
||||||
|
("anchor_drift", drifted),
|
||||||
|
("dangerous_action", dangerous),
|
||||||
|
):
|
||||||
|
with self.subTest(name=name), self.assertRaises(SkuSelectionError):
|
||||||
|
_product_exit_projection(_parse_nodes(hierarchy))
|
||||||
|
|
||||||
|
def test_safe_exit_requires_two_consecutive_evidence_projections(self) -> None:
|
||||||
|
device = _RawDevice(_M_FIXTURE.read_text(encoding="utf-8"))
|
||||||
|
flow = _flow(device)
|
||||||
|
flow.exit_sku_panel_safely()
|
||||||
|
self.assertEqual(len(_actions(device, "pressKey")), 1)
|
||||||
|
self.assertGreaterEqual(len(_actions(device, "dumpWindowHierarchy")), 3)
|
||||||
|
with self.assertRaises(SkuSelectionError):
|
||||||
|
flow.exit_sku_panel_safely()
|
||||||
|
self.assertEqual(len(_actions(device, "pressKey")), 1)
|
||||||
|
|
||||||
|
def test_safe_exit_single_hit_then_drift_resets_stability(self) -> None:
|
||||||
|
class SequenceAfterBack(_RawDevice):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(_M_FIXTURE.read_text(encoding="utf-8"))
|
||||||
|
self.frames: list[str] = []
|
||||||
|
|
||||||
|
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
|
if method == "pressKey":
|
||||||
|
self.calls.append(("jsonrpc", method, params, timeout))
|
||||||
|
self.frames = [_PRODUCT_EXIT, "<hierarchy />", _PRODUCT_EXIT, _PRODUCT_EXIT]
|
||||||
|
return ""
|
||||||
|
if method == "dumpWindowHierarchy" and self.frames:
|
||||||
|
self.hierarchy = self.frames.pop(0)
|
||||||
|
return super().jsonrpc_call(method, params, timeout)
|
||||||
|
|
||||||
|
device = SequenceAfterBack()
|
||||||
|
_flow(device, 0.08).exit_sku_panel_safely()
|
||||||
|
self.assertEqual(len(_actions(device, "pressKey")), 1)
|
||||||
|
self.assertEqual(device.frames, [])
|
||||||
|
|
||||||
|
def test_safe_exit_negative_pages_fail_closed_after_one_back(self) -> None:
|
||||||
|
root = ElementTree.fromstring(_PRODUCT_EXIT)
|
||||||
|
title = next(
|
||||||
|
node
|
||||||
|
for node in root.iter("node")
|
||||||
|
if node.get("resource-id") == "com.xunmeng.pinduoduo:id/tv_title"
|
||||||
|
)
|
||||||
|
root.append(ElementTree.fromstring(ElementTree.tostring(title, encoding="unicode")))
|
||||||
|
duplicate = ElementTree.tostring(root, encoding="unicode")
|
||||||
|
dangerous = _PRODUCT_EXIT.replace(
|
||||||
|
"</hierarchy>",
|
||||||
|
'<node text="立即支付" package="com.xunmeng.pinduoduo" '
|
||||||
|
'class="android.widget.TextView" bounds="[36,1800][400,1900]" clickable="true" '
|
||||||
|
'enabled="true" visible-to-user="true" selected="false" scrollable="false" /></hierarchy>',
|
||||||
|
)
|
||||||
|
|
||||||
|
class FixedPostState(_RawDevice):
|
||||||
|
def __init__(self, post: str) -> None:
|
||||||
|
super().__init__(_M_FIXTURE.read_text(encoding="utf-8"))
|
||||||
|
self.post = post
|
||||||
|
|
||||||
|
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
|
if method == "pressKey":
|
||||||
|
self.calls.append(("jsonrpc", method, params, timeout))
|
||||||
|
self.hierarchy = self.post
|
||||||
|
return ""
|
||||||
|
return super().jsonrpc_call(method, params, timeout)
|
||||||
|
|
||||||
|
cases = (
|
||||||
|
("empty", "<hierarchy />"),
|
||||||
|
("panel", _M_FIXTURE.read_text(encoding="utf-8")),
|
||||||
|
("system_ui", _PRODUCT_EXIT.replace("com.xunmeng.pinduoduo", "com.android.systemui")),
|
||||||
|
(
|
||||||
|
"non_pdd_overlay",
|
||||||
|
_PRODUCT_EXIT.replace(
|
||||||
|
"</hierarchy>",
|
||||||
|
'<node content-desc="overlay" package="external.app" '
|
||||||
|
'class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="true" '
|
||||||
|
'enabled="true" visible-to-user="true" selected="false" scrollable="false" /></hierarchy>',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("other_pdd", _PRODUCT_PAGE),
|
||||||
|
("missing", _PRODUCT_EXIT.replace("com.xunmeng.pinduoduo:id/tv_title", "missing", 1)),
|
||||||
|
("duplicate", duplicate),
|
||||||
|
("drift", _PRODUCT_EXIT.replace("[36,1571][1044,1689]", "[35,1571][1044,1689]", 1)),
|
||||||
|
("danger", dangerous),
|
||||||
|
)
|
||||||
|
for name, post in cases:
|
||||||
|
with self.subTest(name=name):
|
||||||
|
device = FixedPostState(post)
|
||||||
|
with self.assertRaises(SkuSelectionError):
|
||||||
|
_flow(device).exit_sku_panel_safely()
|
||||||
|
self.assertEqual(len(_actions(device, "pressKey")), 1)
|
||||||
|
|
||||||
|
version_drift = FixedPostState(_PRODUCT_EXIT)
|
||||||
|
original_app_info = version_drift.app_info
|
||||||
|
|
||||||
|
def drifted_app_info(package_name: str) -> dict[str, str]:
|
||||||
|
value = original_app_info(package_name)
|
||||||
|
if _actions(version_drift, "pressKey"):
|
||||||
|
value["versionName"] = "8.18.0"
|
||||||
|
return value
|
||||||
|
|
||||||
|
version_drift.app_info = drifted_app_info # type: ignore[method-assign]
|
||||||
|
with self.assertRaises(SkuSelectionError):
|
||||||
|
_flow(version_drift).exit_sku_panel_safely()
|
||||||
|
self.assertEqual(len(_actions(version_drift, "pressKey")), 1)
|
||||||
|
|
||||||
|
foreground_drift = FixedPostState(_PRODUCT_EXIT)
|
||||||
|
original_app_current = foreground_drift.app_current
|
||||||
|
|
||||||
|
def drifted_app_current() -> dict[str, str]:
|
||||||
|
value = original_app_current()
|
||||||
|
if _actions(foreground_drift, "pressKey"):
|
||||||
|
value["package"] = "external.app"
|
||||||
|
return value
|
||||||
|
|
||||||
|
foreground_drift.app_current = drifted_app_current # type: ignore[method-assign]
|
||||||
|
with self.assertRaises(SkuSelectionError):
|
||||||
|
_flow(foreground_drift).exit_sku_panel_safely()
|
||||||
|
self.assertEqual(len(_actions(foreground_drift, "pressKey")), 1)
|
||||||
|
|
||||||
|
|
||||||
class _CompletedFlow:
|
class _CompletedFlow:
|
||||||
"""仅隔离 runner 文件发布测试,同时必须形成完整动作审计链。"""
|
"""仅隔离 runner 文件发布测试,同时必须形成完整动作审计链。"""
|
||||||
@@ -1212,6 +1380,7 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
|||||||
manifest = result.manifest_path.read_text(encoding="utf-8")
|
manifest = result.manifest_path.read_text(encoding="utf-8")
|
||||||
manifest_data = json.loads(manifest)
|
manifest_data = json.loads(manifest)
|
||||||
self.assertTrue(result.screenshot_path.is_file())
|
self.assertTrue(result.screenshot_path.is_file())
|
||||||
|
self.assertEqual(result.screenshot_path, target / "screenshot.png")
|
||||||
self.assertNotIn("device-1", manifest)
|
self.assertNotIn("device-1", manifest)
|
||||||
self.assertNotIn("hierarchy", manifest)
|
self.assertNotIn("hierarchy", manifest)
|
||||||
self.assertNotIn("已选", manifest)
|
self.assertNotIn("已选", manifest)
|
||||||
@@ -1220,8 +1389,13 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
|||||||
self.assertIn('"panel_status": "verified_before_back"', manifest)
|
self.assertIn('"panel_status": "verified_before_back"', manifest)
|
||||||
self.assertIn('"back_attempts": 1', manifest)
|
self.assertIn('"back_attempts": 1', manifest)
|
||||||
self.assertIn('"back_rpc_outcome": "completed"', manifest)
|
self.assertIn('"back_rpc_outcome": "completed"', manifest)
|
||||||
self.assertIn('"post_exit_status": "human_review_required"', manifest)
|
self.assertIn('"post_exit_status": "same_product_verified"', manifest)
|
||||||
self.assertNotIn('"safe_exit"', manifest)
|
self.assertIn('"safe_exit": "completed"', manifest)
|
||||||
|
self.assertIn('"page_identity": "same_goods_evidence_bound"', manifest)
|
||||||
|
self.assertNotIn("human_review_required", manifest)
|
||||||
|
self.assertIsNotNone(result.captured_at.tzinfo)
|
||||||
|
self.assertEqual(result.captured_at.utcoffset().total_seconds(), 0)
|
||||||
|
self.assertEqual(manifest_data["captured_at"], result.captured_at.isoformat())
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
manifest_data["actions"],
|
manifest_data["actions"],
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user