Files
cmbuyer/client/tests/pdd/test_sku_selection.py
T

2177 lines
100 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.
from __future__ import annotations
import ast
import base64
from contextlib import redirect_stderr, redirect_stdout
from functools import lru_cache
from io import BytesIO, StringIO
import importlib.util
import json
from pathlib import Path
from tempfile import TemporaryDirectory
import unittest
from unittest.mock import patch
from xml.etree import ElementTree
from PIL import Image
import cmbuyer_client.pdd as pdd
import cmbuyer_client.pdd.sku_selection_runner as runner_module
from cmbuyer_client.device.adb import AdbDevice, DeviceConnectionError, DeviceInspection
from cmbuyer_client.pdd import SkuSelectionError, SkuSelectionFlow, SkuSelectionRunner
from cmbuyer_client.pdd.sku_selection import (
_PanelProfile,
SkuPanelDevice,
_action_bounds,
_classify_panel,
_parse_nodes,
resolve_task_selection,
)
from cmbuyer_client.pdd.sku_selection_runner import (
SkuExitSpikeCapturer,
SkuExitSpikeError,
SkuSelectionDeviceAdapterError,
SkuSelectionRunError,
SkuSelectionScreenshotError,
UiautomatorSkuExitAdapter,
UiautomatorSkuPanelAdapter,
safe_failure_stage,
)
_FIXTURES = Path(__file__).with_name("fixtures")
_EMPTY_FIXTURE = _FIXTURES / "sku_panel_empty_8_17_0.xml"
_COLOR_FIXTURE = _FIXTURES / "sku_panel_color_selected_size_hidden_8_17_0.xml"
_CURRENT_ONLY_EMPTY_FIXTURE = _FIXTURES / "sku_panel_empty_current_only_8_17_0.xml"
_CURRENT_ONLY_COLOR_FIXTURE = _FIXTURES / "sku_panel_color_selected_size_hidden_current_only_8_17_0.xml"
_S_FIXTURE = _FIXTURES / "sku_panel_size_s_selected_8_17_0.xml"
_M_FIXTURE = _FIXTURES / "sku_panel_size_m_restored_8_17_0.xml"
_FIXTURE = _M_FIXTURE
_ENTRY_FIXTURE = Path(__file__).with_name("fixtures") / "product_entry_8_17_0.xml"
_TARGET_URL = "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"
_TASK_COLOR = "黑色CHA(纯棉)"
_TASK_SIZE = "M(建议100-115)"
_PRODUCT_PAGE = _ENTRY_FIXTURE.read_text(encoding="utf-8")
def _revealed_current_only() -> str:
# 与 spike 测试共用由已验 M fixture 机械恢复出的 current-only 未选态;生产判据只在源码一处。
from tests.pdd.test_sku_reveal_spike import _candidate_current_only
return _candidate_current_only()
@lru_cache(maxsize=1)
def _png_base64() -> str:
image = Image.new("RGB", (1080, 2376), "white")
raw = BytesIO()
image.save(raw, format="PNG")
return base64.b64encode(raw.getvalue()).decode("ascii")
class _RawDevice:
def __init__(self, hierarchy: str = _PRODUCT_PAGE, screenshot: str | None = None) -> None:
self.hierarchy = hierarchy
self.panel_hierarchy = _CURRENT_ONLY_EMPTY_FIXTURE.read_text(encoding="utf-8")
self.color_hierarchy = _CURRENT_ONLY_COLOR_FIXTURE.read_text(encoding="utf-8")
self.revealed_hierarchy = _revealed_current_only()
self.version = "8.17.0"
self.package = "com.xunmeng.pinduoduo"
self.screenshot = _png_base64() if screenshot is None else screenshot
self.calls: list[tuple[object, ...]] = []
self.fail_color_readback = False
def app_info(self, package_name: str) -> dict[str, str]:
self.calls.append(("app_info", package_name))
return {"versionName": self.version}
def app_current(self) -> dict[str, str]:
self.calls.append(("app_current",))
return {"package": self.package}
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
self.calls.append(("jsonrpc", method, params, timeout))
if method == "dumpWindowHierarchy":
return self.hierarchy
if method == "takeScreenshot":
return self.screenshot
if method == "pressKey":
self.hierarchy = "<hierarchy />"
return ""
if method == "click":
if not isinstance(params, list) or len(params) != 2:
raise AssertionError(params)
self._apply_tap(int(params[0]), int(params[1]))
return ""
if method == "swipe":
if params != [360, 1900, 360, 1300, 30]:
raise AssertionError(params)
self.hierarchy = self.revealed_hierarchy
return ""
raise AssertionError(method)
def _apply_tap(self, x: int, y: int) -> None:
if (
(x, y) == (865, 2218)
and "快要抢光 ¥ 12.88" in self.hierarchy
and "[396,498][895,570]" not in self.hierarchy
):
self.hierarchy = self.panel_hierarchy
return
if (x, y) == (528, 1387):
self.hierarchy = (
_EMPTY_FIXTURE.read_text(encoding="utf-8")
if self.fail_color_readback
else self.color_hierarchy
)
return
if (x, y) == (635, 1624):
self.hierarchy = _M_FIXTURE.read_text(encoding="utf-8")
return
raise AssertionError((x, y))
def window_size(self) -> tuple[int, int]:
self.calls.append(("window_size",))
return 1080, 2376
def select_alternates(self) -> None:
self.panel_hierarchy = _S_FIXTURE.read_text(encoding="utf-8")
if self.hierarchy != _PRODUCT_PAGE:
self.hierarchy = self.panel_hierarchy
def _center(bounds: str) -> tuple[int, int]:
left_top, right_bottom = bounds.split("][")
left, top = (int(value) for value in left_top.removeprefix("[").split(","))
right, bottom = (int(value) for value in right_bottom.removesuffix("]").split(","))
return left + (right - left) // 2, top + (bottom - top) // 2
def _flow(device: _RawDevice, timeout_seconds: float = 0.02) -> SkuSelectionFlow:
now = [0.0]
return SkuSelectionFlow(
UiautomatorSkuPanelAdapter(device, 10),
timeout_seconds,
0.01,
lambda: now[0],
lambda seconds: now.__setitem__(0, now[0] + seconds),
)
def _fast_runner_flow(
device: SkuPanelDevice,
entry_wait_timeout_seconds: float = 0.02,
) -> SkuSelectionFlow:
now = [0.0]
return SkuSelectionFlow(
device,
entry_wait_timeout_seconds,
0.01,
lambda: now[0],
lambda seconds: now.__setitem__(0, now[0] + seconds),
)
def _mutate_unique_node(
hierarchy: str,
*,
attribute: str,
value: str | None,
text: str | None = None,
desc: str | None = None,
class_name: str | None = None,
bounds: str | None = None,
) -> str:
root = ElementTree.fromstring(hierarchy)
matches = [
node
for node in root.iter("node")
if (text is None or node.get("text") == text)
and (desc is None or node.get("content-desc") == desc)
and (class_name is None or node.get("class") == class_name)
and (bounds is None or node.get("bounds") == bounds)
]
if len(matches) != 1:
raise AssertionError(f"fixture node match count: {len(matches)}")
if value is None:
matches[0].attrib.pop(attribute, None)
else:
matches[0].set(attribute, value)
return ElementTree.tostring(root, encoding="unicode")
def _nested_dual_price_layout(
hierarchy: str,
*,
header_bounds: str,
row_bounds: str,
summary_bounds: str,
content_bounds: str,
price_frame_bounds: str,
) -> str:
root = ElementTree.fromstring(hierarchy)
parents = {child: parent for parent in root.iter() for child in parent}
header = next(
node for node in root.iter("node")
if node.get("class") == "android.widget.LinearLayout"
and node.get("bounds") == header_bounds
)
row = next(
node for node in root.iter("node")
if node.get("class") == "android.widget.LinearLayout"
and node.get("bounds") == row_bounds
)
summary = next(
node for node in root.iter("node")
if node.get("class") == "android.widget.TextView"
and node.get("bounds") == summary_bounds
)
parents[row].remove(row)
parents[summary].remove(summary)
def inert(class_name: str, bounds: str) -> ElementTree.Element:
return ElementTree.Element(
"node",
{
"package": "com.xunmeng.pinduoduo",
"class": class_name,
"bounds": bounds,
"clickable": "false",
"enabled": "true",
"visible-to-user": "true",
"selected": "false",
"scrollable": "false",
},
)
content_frame = inert("android.widget.FrameLayout", content_bounds)
relative = inert("android.widget.RelativeLayout", content_bounds)
content = inert("android.view.ViewGroup", content_bounds)
price_frame = inert("android.widget.FrameLayout", price_frame_bounds)
header.append(content_frame)
content_frame.append(relative)
relative.append(content)
content.append(price_frame)
price_frame.append(row)
content.append(summary)
return ElementTree.tostring(root, encoding="unicode")
def _entry_chain(root: ElementTree.Element) -> list[ElementTree.Element]:
parents = {child: parent for parent in root.iter() for child in parent}
child = next(node for node in root.iter("node") if node.get("text") == "快要抢光 ¥ 12.88")
return [child, parents[child]]
def _entry_sibling(root: ElementTree.Element) -> ElementTree.Element:
return next(node for node in root.iter("node") if node.get("text") == "免拼购买")
def _mutate_entry(depth: int, attribute: str, value: str) -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
_entry_chain(root)[depth].set(attribute, value)
return ElementTree.tostring(root, encoding="unicode")
def _without_entry() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
root.remove(_entry_chain(root)[1])
return ElementTree.tostring(root, encoding="unicode")
def _duplicate_entry() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
entry_root = _entry_chain(root)[1]
root.append(ElementTree.fromstring(ElementTree.tostring(entry_root, encoding="unicode")))
return ElementTree.tostring(root, encoding="unicode")
def _extra_entry_action_ancestor() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
action = _entry_chain(root)[1]
root.append(ElementTree.Element("node", dict(action.attrib)))
return ElementTree.tostring(root, encoding="unicode")
def _entry_with_panel_price_marker() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
root.append(ElementTree.Element("node", {"bounds": "[396,498][895,570]"}))
return ElementTree.tostring(root, encoding="unicode")
def _dynamic_product_page(value: str, action_desc: str = "") -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
root.set("dynamic-page-value", value)
_entry_chain(root)[1].set("content-desc", action_desc or "快要抢光¥12.88")
return ElementTree.tostring(root, encoding="unicode")
def _with_overlapping_clickable(package: str, class_name: str, bounds: str) -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
root.append(
ElementTree.Element(
"node",
{
"package": package,
"class": class_name,
"bounds": bounds,
"clickable": "true",
"enabled": "true",
"visible-to-user": "true",
},
)
)
return ElementTree.tostring(root, encoding="unicode")
def _with_action_subtree_child(
text: str,
*,
bounds: str = "[500,2170][600,2200]",
) -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
action = _entry_chain(root)[1]
ElementTree.SubElement(
action,
"node",
{
"text": text,
"package": "com.xunmeng.pinduoduo",
"class": "android.widget.TextView",
"bounds": bounds,
"clickable": "false",
"enabled": "true",
"visible-to-user": "true",
},
)
return ElementTree.tostring(root, encoding="unicode")
def _mutate_entry_sibling(attribute: str, value: str) -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
_entry_sibling(root).set(attribute, value)
return ElementTree.tostring(root, encoding="unicode")
def _without_entry_sibling() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
_entry_chain(root)[1].remove(_entry_sibling(root))
return ElementTree.tostring(root, encoding="unicode")
def _entry_sibling_elsewhere() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
root.append(ElementTree.fromstring(ElementTree.tostring(_entry_sibling(root), encoding="unicode")))
return ElementTree.tostring(root, encoding="unicode")
def _home_with_unverified_entry_labels() -> str:
root = ElementTree.Element("hierarchy")
for index in range(2):
ElementTree.SubElement(
root,
"node",
{
"text": "快要抢光",
"package": "com.xunmeng.pinduoduo",
"class": "android.widget.TextView",
"clickable": "false",
"enabled": "true",
"visible-to-user": "true",
"bounds": f"[{index},{index}][{index + 1},{index + 1}]",
},
)
return ElementTree.tostring(root, encoding="unicode")
def _actions(device: _RawDevice, method: str) -> list[tuple[object, ...]]:
return [call for call in device.calls if call[0] == "jsonrpc" and call[1] == method]
def _tap_centers(device: _RawDevice) -> list[tuple[int, int]]:
return [tuple(call[2]) for call in _actions(device, "click")] # type: ignore[misc]
class _FakeAdb:
def __init__(self) -> None:
self.calls: list[tuple[object, ...]] = []
self.inspection = DeviceInspection(AdbDevice(serial="device-1", state="device"), "PKG110", "16")
self.on_intent: callable | None = None
def inspect(self, serial: str) -> DeviceInspection:
self.calls.append(("inspect", serial))
return self.inspection
def start_pdd_view_intent(self, serial: str, goods_id: str) -> object:
self.calls.append(("intent", serial, goods_id))
if self.on_intent is not None:
self.on_intent()
return object()
class SkuSelectionFlowTests(unittest.TestCase):
def _assert_entry_rejected_without_click(self, hierarchy: str) -> None:
now = [0.0]
device = _RawDevice(hierarchy)
flow = SkuSelectionFlow(
UiautomatorSkuPanelAdapter(device, 10),
0.01,
0.01,
lambda: now[0],
lambda seconds: now.__setitem__(0, now[0] + seconds),
)
with self.assertRaises(SkuSelectionError):
flow.open_sku_panel(_TARGET_URL)
self.assertEqual(_actions(device, "click"), [])
def test_current_only_exact_transition_reveals_then_selects_m_once(self) -> None:
device = _RawDevice()
flow = _flow(device)
flow.open_sku_panel(_TARGET_URL)
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(flow.read_sku_unit_price(), "12.88")
self.assertEqual(_tap_centers(device), [(865, 2218), (528, 1387), (635, 1624)])
self.assertEqual(len(_actions(device, "swipe")), 1)
self.assertEqual(_actions(device, "pressKey"), [])
restored = _RawDevice(_S_FIXTURE.read_text(encoding="utf-8"))
restored_flow = _flow(restored)
restored_flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(restored_flow.read_sku_unit_price(), "12.88")
self.assertEqual(_tap_centers(restored), [(635, 1624)])
self.assertEqual(_actions(restored, "swipe"), [])
def test_dual_color_only_is_zero_action_before_reveal(self) -> None:
for fixture in (_COLOR_FIXTURE, _CURRENT_ONLY_COLOR_FIXTURE):
with self.subTest(fixture=fixture.name):
device = _RawDevice(fixture.read_text(encoding="utf-8"))
with self.assertRaises(SkuSelectionError):
_flow(device).select_sku_options(
resolve_task_selection(_TASK_COLOR, _TASK_SIZE)
)
self.assertEqual(_actions(device, "click"), [])
self.assertEqual(_actions(device, "swipe"), [])
def test_revealed_current_only_is_a_unique_profile_but_not_a_reentry_shortcut(self) -> None:
hierarchy = _revealed_current_only()
spec = _classify_panel(_parse_nodes(hierarchy))
self.assertIs(spec.profile, _PanelProfile.SIZE_VISIBLE_UNSELECTED)
self.assertEqual(spec.price_layout.__class__.__name__, "_CurrentOnlyPriceLayout")
device = _RawDevice(hierarchy)
with self.assertRaises(SkuSelectionError):
_flow(device).select_sku_options(
resolve_task_selection(_TASK_COLOR, _TASK_SIZE)
)
self.assertEqual(_actions(device, "click"), [])
self.assertEqual(_actions(device, "swipe"), [])
def test_revealed_exact_m_contract_rejects_missing_duplicate_disabled_near_and_other_selected(self) -> None:
base = _revealed_current_only()
for kind in ("missing", "duplicate", "disabled", "near", "other_selected"):
root = ElementTree.fromstring(base)
parents = {child: parent for parent in root.iter() for child in parent}
m_action = next(
node for node in root.iter("node")
if node.get("text") == "M(建议100-115)"
)
wrapper = parents[m_action]
if kind == "missing":
parents[wrapper].remove(wrapper)
elif kind == "duplicate":
parents[wrapper].append(
ElementTree.fromstring(ElementTree.tostring(wrapper, encoding="unicode"))
)
elif kind == "disabled":
m_action.set("enabled", "false")
elif kind == "near":
m_action.set("text", "M(建议100-115)相近")
else:
s_action = next(
node for node in root.iter("node")
if node.get("text") == "S(建议80-100)"
)
s_action.set("selected", "true")
with self.subTest(kind=kind), self.assertRaises(SkuSelectionError):
_classify_panel(
_parse_nodes(ElementTree.tostring(root, encoding="unicode"))
)
def test_reveal_requires_two_consecutive_stable_frames_and_terminal_blocks_reentry(self) -> None:
class UnstableRevealDevice(_RawDevice):
def __init__(self) -> None:
super().__init__(_CURRENT_ONLY_EMPTY_FIXTURE.read_text(encoding="utf-8"))
self.after_reveal_reads = 0
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "dumpWindowHierarchy" and _actions(self, "swipe"):
self.after_reveal_reads += 1
candidate = _revealed_current_only()
self.hierarchy = (
candidate
if self.after_reveal_reads % 2
else candidate.replace("请选择: 尺码", "未知摘要")
)
return super().jsonrpc_call(method, params, timeout)
device = UnstableRevealDevice()
flow = _flow(device)
with self.assertRaises(SkuSelectionError):
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
action_count = len(_actions(device, "click")) + len(_actions(device, "swipe"))
self.assertEqual(len(_actions(device, "swipe")), 1)
self.assertEqual(_tap_centers(device), [(528, 1387)])
with self.assertRaisesRegex(SkuSelectionError, "不可重入终止态"):
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(
len(_actions(device, "click")) + len(_actions(device, "swipe")),
action_count,
)
def test_reveal_timeout_is_read_only_reconciled_without_m_back_or_retry(self) -> None:
class RevealTimeoutDevice(_RawDevice):
def __init__(self, delivered: bool) -> None:
super().__init__(_CURRENT_ONLY_EMPTY_FIXTURE.read_text(encoding="utf-8"))
self.delivered = delivered
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "swipe":
if self.delivered:
super().jsonrpc_call(method, params, timeout)
else:
self.calls.append(("jsonrpc", method, params, timeout))
raise TimeoutError("private reveal timeout")
return super().jsonrpc_call(method, params, timeout)
for delivered in (False, True):
with self.subTest(delivered=delivered):
device = RevealTimeoutDevice(delivered)
flow = _flow(device)
with self.assertRaises(SkuSelectionRunError):
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
try:
flow.reconcile_pending_action()
except SkuSelectionError:
pass
self.assertEqual(len(_actions(device, "swipe")), 1)
self.assertEqual(_tap_centers(device), [(528, 1387)])
self.assertEqual(_actions(device, "pressKey"), [])
with self.assertRaisesRegex(SkuSelectionError, "不可重入终止态"):
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(len(_actions(device, "swipe")), 1)
def test_m_timeout_is_read_only_reconciled_without_back_or_retry(self) -> None:
class MTimeoutDevice(_RawDevice):
def __init__(self, delivered: bool) -> None:
super().__init__(_CURRENT_ONLY_EMPTY_FIXTURE.read_text(encoding="utf-8"))
self.delivered = delivered
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "click" and params == [635, 1624]:
if self.delivered:
super().jsonrpc_call(method, params, timeout)
else:
self.calls.append(("jsonrpc", method, params, timeout))
raise TimeoutError("private M timeout")
return super().jsonrpc_call(method, params, timeout)
for delivered in (False, True):
with self.subTest(delivered=delivered):
device = MTimeoutDevice(delivered)
flow = _flow(device)
with self.assertRaises(SkuSelectionRunError):
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
try:
flow.reconcile_pending_action()
except SkuSelectionError:
pass
self.assertEqual(len(_actions(device, "swipe")), 1)
self.assertEqual(_tap_centers(device), [(528, 1387), (635, 1624)])
self.assertEqual(_actions(device, "pressKey"), [])
with self.assertRaisesRegex(SkuSelectionError, "不可重入终止态"):
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(_tap_centers(device), [(528, 1387), (635, 1624)])
def test_foreground_is_freshly_rechecked_before_s_reveal_and_m_mutations(self) -> None:
class ForegroundDriftDevice(_RawDevice):
def __init__(self, hierarchy: str, flip_at: int) -> None:
super().__init__(hierarchy)
self.flip_at = flip_at
self.current_reads = 0
def app_current(self) -> dict[str, str]:
current = super().app_current()
self.current_reads += 1
if self.current_reads == self.flip_at:
return {"package": "other"}
return current
cases = (
(
"s",
_S_FIXTURE.read_text(encoding="utf-8"),
2,
[],
0,
),
(
"reveal",
_CURRENT_ONLY_EMPTY_FIXTURE.read_text(encoding="utf-8"),
4,
[(528, 1387)],
0,
),
(
"m",
_CURRENT_ONLY_EMPTY_FIXTURE.read_text(encoding="utf-8"),
7,
[(528, 1387)],
1,
),
)
for name, hierarchy, flip_at, expected_taps, expected_swipes in cases:
with self.subTest(name=name):
device = ForegroundDriftDevice(hierarchy, flip_at)
with self.assertRaises(SkuSelectionError):
_flow(device).select_sku_options(
resolve_task_selection(_TASK_COLOR, _TASK_SIZE)
)
self.assertEqual(_tap_centers(device), expected_taps)
self.assertEqual(len(_actions(device, "swipe")), expected_swipes)
self.assertEqual(_actions(device, "pressKey"), [])
def test_current_only_empty_to_color_only_uses_the_matching_exact_spec(self) -> None:
device = _RawDevice()
device.panel_hierarchy = _CURRENT_ONLY_EMPTY_FIXTURE.read_text(encoding="utf-8")
device.color_hierarchy = _CURRENT_ONLY_COLOR_FIXTURE.read_text(encoding="utf-8")
flow = _flow(device)
flow.open_sku_panel(_TARGET_URL)
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(_tap_centers(device), [(865, 2218), (528, 1387), (635, 1624)])
self.assertEqual(len(_actions(device, "swipe")), 1)
matched = _classify_panel(_parse_nodes(device.hierarchy))
self.assertIs(matched.profile, _PanelProfile.TARGETS_SELECTED)
self.assertEqual(
matched.price_layout.__class__.__name__,
"_DualPriceLayout",
)
def test_dual_and_current_only_variants_each_match_one_complete_spec(self) -> None:
cases = (
(_EMPTY_FIXTURE, _PanelProfile.PANEL_OPEN_EMPTY, "_DualPriceLayout"),
(_COLOR_FIXTURE, _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN, "_DualPriceLayout"),
(_CURRENT_ONLY_EMPTY_FIXTURE, _PanelProfile.PANEL_OPEN_EMPTY, "_CurrentOnlyPriceLayout"),
(_CURRENT_ONLY_COLOR_FIXTURE, _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN, "_CurrentOnlyPriceLayout"),
)
for fixture, profile, layout_name in cases:
with self.subTest(fixture=fixture.name):
spec = _classify_panel(_parse_nodes(fixture.read_text(encoding="utf-8")))
self.assertIs(spec.profile, profile)
self.assertEqual(spec.price_layout.__class__.__name__, layout_name)
def test_dual_price_historical_nested_parent_chains_remain_accepted(self) -> None:
for fixture, expected_profile in (
(_EMPTY_FIXTURE, _PanelProfile.PANEL_OPEN_EMPTY),
(_COLOR_FIXTURE, _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN),
):
with self.subTest(fixture=fixture.name):
nested = _nested_dual_price_layout(
fixture.read_text(encoding="utf-8"),
header_bounds="[0,366][1080,1077]",
row_bounds="[396,575][912,647]",
summary_bounds="[396,731][1053,793]",
content_bounds="[0,551][1080,940]",
price_frame_bounds="[396,575][1053,647]",
)
spec = _classify_panel(_parse_nodes(nested))
self.assertIs(spec.profile, expected_profile)
self.assertEqual(spec.price_layout.__class__.__name__, "_DualPriceLayout")
nested_s = _nested_dual_price_layout(
_S_FIXTURE.read_text(encoding="utf-8"),
header_bounds="[0,366][1080,1000]",
row_bounds="[396,498][895,570]",
summary_bounds="[396,654][1053,716]",
content_bounds="[0,474][1080,863]",
price_frame_bounds="[396,498][1053,570]",
)
self.assertIs(
_classify_panel(_parse_nodes(nested_s)).profile,
_PanelProfile.SIZE_VISIBLE_NON_TARGET,
)
nested_m = _nested_dual_price_layout(
_M_FIXTURE.read_text(encoding="utf-8"),
header_bounds="[0,366][1080,1000]",
row_bounds="[396,498][895,570]",
summary_bounds="[396,654][1053,716]",
content_bounds="[0,474][1080,863]",
price_frame_bounds="[396,498][1053,570]",
)
self.assertEqual(_flow(_RawDevice(nested_m)).read_sku_unit_price(), "12.88")
def test_current_only_price_variant_rejects_cross_variant_duplicate_unknown_and_parent_drift(self) -> None:
base = _CURRENT_ONLY_EMPTY_FIXTURE.read_text(encoding="utf-8")
def mutate(kind: str) -> str:
root = ElementTree.fromstring(base)
parents = {child: parent for parent in root.iter() for child in parent}
frame = next(
node for node in root.iter("node")
if node.get("class") == "android.widget.FrameLayout"
and node.get("bounds") == "[396,575][1053,647]"
)
row = next(
node for node in frame
if node.get("class") == "android.widget.LinearLayout"
and node.get("bounds") == "[396,575][693,647]"
)
current = next(node for node in row if node.get("text") == "限1件 ¥12.88 ")
if kind == "cross_variant":
ElementTree.SubElement(
row,
"node",
{
"text": "券前¥29.88",
"package": "com.xunmeng.pinduoduo",
"class": "android.widget.TextView",
"bounds": "[693,580][912,647]",
"clickable": "false",
"enabled": "true",
"visible-to-user": "true",
"selected": "false",
"scrollable": "false",
},
)
elif kind == "duplicate_frame":
parents[frame].append(ElementTree.fromstring(ElementTree.tostring(frame, encoding="unicode")))
elif kind == "duplicate_current":
row.append(ElementTree.fromstring(ElementTree.tostring(current, encoding="unicode")))
elif kind == "unknown_text":
current.set("text", "未知促销文字")
elif kind == "unknown_bounds":
frame.set("bounds", "[396,574][1053,647]")
elif kind == "unknown_child":
row.append(ElementTree.Element("node", dict(current.attrib) | {"text": "未知节点"}))
elif kind == "parent_drift":
frame.remove(row)
parents[frame].append(row)
elif kind == "summary_parent_drift":
summary = next(
node for node in root.iter("node")
if node.get("bounds") == "[396,731][1053,793]"
and node.get("class") == "android.widget.TextView"
)
parents[summary].remove(summary)
parents[parents[summary]].append(summary)
else:
raise AssertionError(kind)
return ElementTree.tostring(root, encoding="unicode")
for kind in (
"cross_variant",
"duplicate_frame",
"duplicate_current",
"unknown_text",
"unknown_bounds",
"unknown_child",
"parent_drift",
"summary_parent_drift",
):
with self.subTest(kind=kind), self.assertRaises(SkuSelectionError):
_classify_panel(_parse_nodes(mutate(kind)))
def test_color_click_rejects_dual_current_only_cross_variant_in_both_directions(self) -> None:
cases = (
(_CURRENT_ONLY_EMPTY_FIXTURE, _COLOR_FIXTURE),
(_EMPTY_FIXTURE, _CURRENT_ONLY_COLOR_FIXTURE),
)
for empty_fixture, color_fixture in cases:
with self.subTest(empty=empty_fixture.name, color=color_fixture.name):
device = _RawDevice()
device.panel_hierarchy = empty_fixture.read_text(encoding="utf-8")
device.color_hierarchy = color_fixture.read_text(encoding="utf-8")
flow = _flow(device)
flow.open_sku_panel(_TARGET_URL)
with self.assertRaises(SkuSelectionError) as raised:
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertNotIn("受控显示动作尚未取证", str(raised.exception))
expected = [(865, 2218), (528, 1387)] if empty_fixture is _CURRENT_ONLY_EMPTY_FIXTURE else [(865, 2218)]
self.assertEqual(_tap_centers(device), expected)
self.assertEqual(_actions(device, "swipe"), [])
self.assertEqual(_actions(device, "pressKey"), [])
def test_full_verified_entry_structure_taps_exact_text_child_once(self) -> None:
device = _RawDevice()
_flow(device).open_sku_panel(_TARGET_URL)
self.assertEqual(_tap_centers(device), [(865, 2218)])
self.assertNotIn((763, 2247), _tap_centers(device)) # 父容器中心不是获准目标。
self.assertNotIn((772, 2280), _tap_centers(device)) # 第二行“免拼购买”永不点击。
def test_entry_stability_uses_verified_chain_projection_not_whole_xml(self) -> None:
class DynamicProductDevice(_RawDevice):
def __init__(self) -> None:
super().__init__()
self.frame = 0
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "dumpWindowHierarchy" and "快要抢光" in self.hierarchy:
self.frame += 1
self.hierarchy = _dynamic_product_page(str(self.frame))
return super().jsonrpc_call(method, params, timeout)
now = [0.0]
device = DynamicProductDevice()
flow = SkuSelectionFlow(
UiautomatorSkuPanelAdapter(device, 10),
0.03,
0.01,
lambda: now[0],
lambda seconds: now.__setitem__(0, now[0] + seconds),
)
flow.open_sku_panel(_TARGET_URL, "<hierarchy />")
self.assertEqual(device.frame, 2)
self.assertEqual(_tap_centers(device), [(865, 2218)])
def test_entry_action_description_must_be_stable_across_frames(self) -> None:
class ChangingActionDescriptionDevice(_RawDevice):
def __init__(self) -> None:
super().__init__()
self.frame = 0
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "dumpWindowHierarchy":
self.frame += 1
self.hierarchy = _dynamic_product_page(
str(self.frame),
f"活动{self.frame}快要抢光",
)
return super().jsonrpc_call(method, params, timeout)
now = [0.0]
device = ChangingActionDescriptionDevice()
flow = SkuSelectionFlow(
UiautomatorSkuPanelAdapter(device, 10),
0.02,
0.01,
lambda: now[0],
lambda seconds: now.__setitem__(0, now[0] + seconds),
)
with self.assertRaises(SkuSelectionError):
flow.open_sku_panel(_TARGET_URL, "<hierarchy />")
self.assertEqual(_actions(device, "click"), [])
def test_unverified_home_entry_labels_do_not_trigger_old_product_rejection(self) -> None:
device = _RawDevice()
_flow(device).open_sku_panel(
_TARGET_URL,
_home_with_unverified_entry_labels(),
)
self.assertEqual(_tap_centers(device), [(865, 2218)])
def test_entry_child_and_every_ancestor_attribute_drift_never_clicks(self) -> None:
expected_clickable = ("false", "true")
for depth in range(2):
changes = {
"package": "other.package",
"class": "android.widget.Button",
"bounds": "[1,1][2,2]",
"clickable": "true" if expected_clickable[depth] == "false" else "false",
"enabled": "false",
"visible-to-user": "false",
}
for attribute, value in changes.items():
with self.subTest(depth=depth, attribute=attribute):
self._assert_entry_rejected_without_click(_mutate_entry(depth, attribute, value))
def test_entry_chain_text_and_description_drift_never_clicks(self) -> None:
cases = [
_mutate_entry(1, "text", "祖先文字漂移")
] + [
_mutate_entry(0, "content-desc", "祖先描述漂移"),
_mutate_entry(1, "content-desc", "祖先描述漂移"),
] + [
_mutate_entry(1, "content-desc", forbidden + "快要抢光")
for forbidden in (
"免拼购买", "单独购买", "直接拼成", "提交订单", "支付", "先用后付", "0元下单",
"立即购买", "确认下单", "立即付款", "订单详情",
)
]
for hierarchy in cases:
with self.subTest():
self._assert_entry_rejected_without_click(hierarchy)
def test_any_live_clickable_covering_entry_center_blocks_click(self) -> None:
cases = (
_with_overlapping_clickable(
"com.xunmeng.pinduoduo",
"android.widget.Button",
"[850,2200][900,2230]",
),
_with_overlapping_clickable(
"com.android.systemui",
"android.view.ViewGroup",
"[800,2100][1000,2300]",
),
_with_overlapping_clickable(
"com.android.systemui",
"android.view.ViewGroup",
"not-a-bound",
),
)
for hierarchy in cases:
with self.subTest():
self._assert_entry_rejected_without_click(hierarchy)
def test_pre_intent_old_entry_is_rejected_even_with_extra_action_node(self) -> None:
for old_page in (
_extra_entry_action_ancestor(),
_entry_with_panel_price_marker(),
_mutate_entry(1, "content-desc", "立即购买快要抢光"),
):
with self.subTest():
device = _RawDevice()
flow = _flow(device)
with self.assertRaises(SkuSelectionError):
flow.open_sku_panel(_TARGET_URL, old_page)
self.assertEqual(_actions(device, "click"), [])
def test_action_subtree_dangerous_or_ambiguous_children_never_click(self) -> None:
cases = (
_with_action_subtree_child("提交订单"),
_with_action_subtree_child("免拼购买"),
_with_action_subtree_child("快要抢光"),
)
for hierarchy in cases:
with self.subTest():
self._assert_entry_rejected_without_click(hierarchy)
def test_duplicate_entry_and_forbidden_sibling_entry_never_click(self) -> None:
self._assert_entry_rejected_without_click(_duplicate_entry())
self._assert_entry_rejected_without_click(_extra_entry_action_ancestor())
self._assert_entry_rejected_without_click(_without_entry())
self._assert_entry_rejected_without_click(_mutate_entry(0, "clickable", "true"))
def test_header_promotion_label_alone_never_becomes_entry(self) -> None:
self._assert_entry_rejected_without_click(_without_entry())
def test_entry_leaf_and_parent_amount_identity_drift_never_clicks(self) -> None:
for hierarchy in (
_PRODUCT_PAGE.replace('text="快要抢光 ¥ 12.88"', 'text="快要抢光 ¥ 13.88"'),
_mutate_entry(1, "content-desc", "快要抢光¥13.88"),
):
with self.subTest():
self._assert_entry_rejected_without_click(hierarchy)
def test_entry_sibling_is_exact_inert_unique_and_in_same_parent(self) -> None:
cases = (
_without_entry_sibling(),
_mutate_entry_sibling("text", "免拼购买 "),
_mutate_entry_sibling("bounds", "[688,2255][856,2305]"),
_mutate_entry_sibling("clickable", "true"),
_with_action_subtree_child("免拼购买", bounds="[500,2260][650,2300]"),
_entry_sibling_elsewhere(),
)
for hierarchy in cases:
with self.subTest():
self._assert_entry_rejected_without_click(hierarchy)
def test_unknown_task_or_ui_variants_are_rejected_without_action(self) -> None:
for color, size in (("黑色 CHA (纯棉)", _TASK_SIZE), (_TASK_COLOR, "M(建议100-115)"), ("黑色CHA(纯棉)", _TASK_SIZE)):
with self.subTest(color=color, size=size), self.assertRaises(SkuSelectionError):
resolve_task_selection(color, size)
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8"))
with self.assertRaises(SkuSelectionError):
_flow(device).select_sku_options(
resolve_task_selection(_TASK_COLOR, _TASK_SIZE).__class__("粉红", "L(建议115-130)")
)
self.assertEqual(_actions(device, "click"), [])
def test_option_selected_and_container_drift_fail_closed_before_click(self) -> None:
base = _FIXTURE.read_text(encoding="utf-8")
cases = (
_mutate_unique_node(base, desc="黑色 CHA (纯棉)", class_name="android.view.ViewGroup", attribute="selected", value=None),
_mutate_unique_node(base, desc="黑色 CHA (纯棉)", class_name="android.view.ViewGroup", attribute="selected", value="maybe"),
_mutate_unique_node(base, desc="黑色 CHA (纯棉)", class_name="android.view.ViewGroup", attribute="bounds", value="[1,1][20,20]"),
_mutate_unique_node(base, desc="黑色 CHA (纯棉)", class_name="android.view.ViewGroup", attribute="enabled", value="false"),
)
for hierarchy in cases:
with self.subTest(), self.assertRaises(SkuSelectionError):
_flow(_RawDevice(hierarchy)).select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
def test_unknown_clickable_action_ancestor_is_rejected_by_profile(self) -> None:
root = ElementTree.fromstring(_EMPTY_FIXTURE.read_text(encoding="utf-8"))
parents = {child: parent for parent in root.iter() for child in parent}
target = next(
node
for node in root.iter("node")
if node.get("content-desc") == "黑色 CHA (纯棉)"
and node.get("class") == "android.view.ViewGroup"
)
region = parents[target]
region.remove(target)
unknown = ElementTree.SubElement(
region,
"node",
{
"package": "com.xunmeng.pinduoduo",
"class": "android.view.ViewGroup",
"bounds": "[0,1188][1080,2046]",
"clickable": "true",
"enabled": "true",
"visible-to-user": "true",
"selected": "false",
"scrollable": "false",
},
)
unknown.append(target)
with self.assertRaises(SkuSelectionError):
_classify_panel(_parse_nodes(ElementTree.tostring(root, encoding="unicode")))
def test_invalid_bounds_stop_before_action(self) -> None:
for bounds in ("", "[1,2][1,3]", "[1,2][3,2]", "[0,0][1081,1]", "[0,0][1,2377]", "[a,0][1,1]"):
with self.subTest(bounds=bounds), self.assertRaises(SkuSelectionError):
_action_bounds(bounds)
device = _RawDevice(_PRODUCT_PAGE.replace("[688,2184][1042,2253]", "[0,0][1081,1]"))
with self.assertRaises(SkuSelectionError):
_flow(device).open_sku_panel(_TARGET_URL)
self.assertEqual(_actions(device, "click"), [])
def test_color_readback_failure_never_attempts_second_option(self) -> None:
device = _RawDevice()
device.fail_color_readback = True
flow = _flow(device)
flow.open_sku_panel(_TARGET_URL)
with self.assertRaises(SkuSelectionError):
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(_tap_centers(device), [(865, 2218), (528, 1387)])
def test_non_target_selection_restores_each_dimension_once(self) -> None:
device = _RawDevice()
flow = _flow(device)
flow.open_sku_panel(_TARGET_URL)
device.select_alternates()
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(_tap_centers(device), [(865, 2218), (635, 1624)])
self.assertEqual(_actions(device, "swipe"), [])
def test_price_rejects_coupon_prefix_extra_amount_and_bottom_action(self) -> None:
for replacement in ("券后 ¥12.88", "会员补贴 ¥12.88", "到手 ¥12.88", "实付 ¥12.88", "区间 ¥12.88", "原价 ¥12.88", "划线价 ¥12.88", "最低 ¥12.88", "低至 ¥12.88", "起价 ¥12.88", "快卖完 1 ¥12.88", "快卖完 ¥12.88 ¥11.88"):
with self.subTest(replacement=replacement):
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8").replace("快卖完 ¥12.88", replacement))
with self.assertRaises(SkuSelectionError):
_flow(device).read_sku_unit_price()
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8").replace("快卖完 ¥12.88", "提交订单 ¥12.88"))
with self.assertRaises(SkuSelectionError):
_flow(device).read_sku_unit_price()
clickable_parent = _mutate_unique_node(
_FIXTURE.read_text(encoding="utf-8"),
class_name="android.widget.LinearLayout",
bounds="[396,498][895,570]",
attribute="clickable",
value="true",
)
with self.assertRaises(SkuSelectionError):
_flow(_RawDevice(clickable_parent)).read_sku_unit_price()
def test_public_api_and_protocol_have_no_broad_or_order_operations(self) -> None:
forbidden = {"quantity", "confirm", "authorization", "fence", "submit", "payment", "click", "swipe", "scroll"}
self.assertTrue(forbidden.isdisjoint(SkuSelectionFlow.__dict__))
self.assertTrue(forbidden.isdisjoint(SkuPanelDevice.__dict__))
self.assertTrue(forbidden.isdisjoint(pdd.__all__))
def test_static_ast_boundary_limits_flow_runner_adapter_and_cli(self) -> None:
root = Path(__file__).resolve().parents[2]
files = (
root / "src" / "cmbuyer_client" / "pdd" / "sku_selection.py",
root / "src" / "cmbuyer_client" / "pdd" / "sku_selection_runner.py",
root / "scripts" / "run_t103_sku_selection.py",
root / "src" / "cmbuyer_client" / "pdd" / "sku_reveal_spike.py",
root / "scripts" / "capture_sku_reveal_spike.py",
)
forbidden = ("quantity", "confirm", "authorization", "fence", "submit_order", "payment")
for path in files:
source = path.read_text(encoding="utf-8")
with self.subTest(path=path.name):
self.assertTrue(all(token not in source.lower() for token in forbidden))
tree = ast.parse(source)
self.assertFalse(any(isinstance(node, ast.ImportFrom) and node.module in {"selenium", "requests"} for node in ast.walk(tree)))
runner_tree = ast.parse(files[1].read_text(encoding="utf-8"))
click_calls = [node for node in ast.walk(runner_tree) if isinstance(node, ast.Constant) and node.value == "click"]
self.assertEqual(len(click_calls), 1)
swipe_calls = [node for node in ast.walk(runner_tree) if isinstance(node, ast.Constant) and node.value == "swipe"]
self.assertEqual(len(swipe_calls), 1)
def test_entry_wait_rejects_unchanged_or_duplicate_page_without_click(self) -> None:
now = [0.0]
device = _RawDevice()
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10), 0.01, 0.01, lambda: now[0], lambda seconds: now.__setitem__(0, now[0] + seconds))
with self.assertRaises(SkuSelectionError):
flow.open_sku_panel(_TARGET_URL, _PRODUCT_PAGE)
self.assertEqual(_actions(device, "click"), [])
def test_action_postcondition_wait_never_repeats_entry_click(self) -> None:
class NoPanelAfterEntry(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "click":
self.calls.append(("jsonrpc", method, params, timeout))
return ""
return super().jsonrpc_call(method, params, timeout)
device = NoPanelAfterEntry()
with self.assertRaises(SkuSelectionError):
_flow(device).open_sku_panel(_TARGET_URL)
self.assertEqual(_tap_centers(device), [(865, 2218)])
duplicate = _duplicate_entry()
device = _RawDevice(duplicate)
with self.assertRaises(SkuSelectionError):
_flow(device).open_sku_panel(_TARGET_URL)
self.assertEqual(_actions(device, "click"), [])
def test_fixture_contains_no_address_phone_or_payment_credentials(self) -> None:
for fixture in (
_EMPTY_FIXTURE,
_COLOR_FIXTURE,
_CURRENT_ONLY_EMPTY_FIXTURE,
_CURRENT_ONLY_COLOR_FIXTURE,
_S_FIXTURE,
_M_FIXTURE,
_ENTRY_FIXTURE,
):
content = fixture.read_text(encoding="utf-8")
with self.subTest(fixture=fixture.name):
self.assertNotRegex(content, r"1[3-9]\d{9}")
for forbidden in ("地址", "收货", "支付", "银行卡", "身份证"):
self.assertNotIn(forbidden, content)
self.assertNotIn("提交订单", content)
class _CompletedFlow:
"""仅隔离 runner 文件发布测试,同时必须形成完整动作审计链。"""
def __init__(self, device: object, *args: object, **kwargs: object) -> None:
self.device = device
def open_sku_panel(self, product_url: str, pre_intent_hierarchy: str | None = None) -> None:
self.device.tap_sku_entry("[688,2184][1042,2253]")
def select_sku_options(self, selection: object) -> None:
self.device.tap_sku_option("[372,1188][684,1587]")
self.device.reveal_size_options_once()
self.device.tap_sku_option("[439,1582][831,1667]")
def verify_target_selection_and_read_price(self, selection: object) -> str:
return "12.88"
def exit_sku_panel_safely(self) -> None:
self.device.leave_sku_panel()
def reconcile_pending_action(self) -> None:
return None
class SkuSelectionRunnerTests(unittest.TestCase):
def setUp(self) -> None:
flow_patch = patch.object(runner_module, "SkuSelectionFlow", _fast_runner_flow)
flow_patch.start()
self.addCleanup(flow_patch.stop)
def _runner(self, adb: _FakeAdb, device: _RawDevice) -> SkuSelectionRunner:
device.hierarchy = "<hierarchy />"
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE.replace("<hierarchy>", '<hierarchy post-intent="1">'))
return SkuSelectionRunner(adb, lambda serial: device, 0.03)
def test_runner_atomically_publishes_screenshot_and_redacted_manifest(self) -> None:
adb = _FakeAdb()
device = _RawDevice()
with TemporaryDirectory() as temporary:
target = Path(temporary) / "result"
with patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow):
result = self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertEqual(result.unit_price, "12.88")
manifest = result.manifest_path.read_text(encoding="utf-8")
manifest_data = json.loads(manifest)
self.assertTrue(result.screenshot_path.is_file())
self.assertNotIn("device-1", manifest)
self.assertNotIn("hierarchy", manifest)
self.assertNotIn("已选", manifest)
self.assertIn('"unit_price": "12.88"', manifest)
self.assertIn('"selection_status": "restored"', manifest)
self.assertIn('"panel_status": "verified_before_back"', manifest)
self.assertIn('"back_attempts": 1', manifest)
self.assertIn('"back_rpc_outcome": "completed"', manifest)
self.assertIn('"post_exit_status": "human_review_required"', manifest)
self.assertNotIn('"safe_exit"', manifest)
self.assertEqual(
manifest_data["actions"],
{
"sku_entry": {"attempts": 1, "rpc_outcome": "completed"},
"target_color": {"attempts": 1, "rpc_outcome": "completed"},
"size_reveal": {"attempts": 1, "rpc_outcome": "completed"},
"target_size": {"attempts": 1, "rpc_outcome": "completed"},
"back": {"attempts": 1, "rpc_outcome": "completed"},
},
)
self.assertFalse((target / "hierarchy.xml").exists())
self.assertEqual(len(_actions(device, "pressKey")), 1)
def test_publish_rejects_flow_stub_without_exact_action_audit_chain(self) -> None:
class IncompleteFlow(_CompletedFlow):
def open_sku_panel(self, product_url: str, pre_intent_hierarchy: str | None = None) -> None:
return None
def select_sku_options(self, selection: object) -> None:
return None
def exit_sku_panel_safely(self) -> None:
return None
device = _RawDevice()
with TemporaryDirectory() as temporary:
target = Path(temporary) / "result"
with (
patch.object(runner_module, "SkuSelectionFlow", IncompleteFlow),
self.assertRaises(SkuSelectionRunError) as raised,
):
self._runner(_FakeAdb(), device).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target
)
self.assertEqual(safe_failure_stage(raised.exception), "safe_exit")
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
self.assertEqual(_actions(device, "pressKey"), [])
def test_failure_stage_is_fixed_control_flow_metadata_without_error_text(self) -> None:
class NoPanelAfterEntry(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "click":
self.calls.append(("jsonrpc", method, params, timeout))
return ""
return super().jsonrpc_call(method, params, timeout)
adb = _FakeAdb()
device = NoPanelAfterEntry("<hierarchy />")
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE)
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError) as raised:
SkuSelectionRunner(adb, lambda serial: device, 0.03).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
)
self.assertEqual(safe_failure_stage(raised.exception), "sku_entry_panel_verify")
forged = SkuSelectionError("<hierarchy>private</hierarchy>")
setattr(forged, "_cmbuyer_failure_stage", "sku_entry_click")
self.assertEqual(safe_failure_stage(forged), "unknown")
class HostileSetterError(DeviceConnectionError):
def __setattr__(self, name: str, value: object) -> None:
raise KeyboardInterrupt("SERIAL=private <hierarchy>secret</hierarchy>")
class FailingAdb(_FakeAdb):
def inspect(self, serial: str) -> DeviceInspection:
raise HostileSetterError("private")
with TemporaryDirectory() as temporary, self.assertRaises(HostileSetterError) as hostile:
SkuSelectionRunner(FailingAdb(), lambda serial: _RawDevice(), 10).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
)
self.assertEqual(safe_failure_stage(hostile.exception), "unknown")
def test_failure_stage_identifies_sku_entry_pre_intent(self) -> None:
device = _RawDevice(_PRODUCT_PAGE)
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError) as raised:
SkuSelectionRunner(_FakeAdb(), lambda serial: device, 0.03).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
)
self.assertEqual(safe_failure_stage(raised.exception), "sku_entry_pre_intent")
self.assertEqual(_actions(device, "click"), [])
def test_failure_stage_identifies_sku_entry_discovery(self) -> None:
device = _RawDevice("<hierarchy />")
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError) as raised:
SkuSelectionRunner(_FakeAdb(), lambda serial: device, 0.01).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
)
self.assertEqual(safe_failure_stage(raised.exception), "sku_entry_discovery")
self.assertEqual(_actions(device, "click"), [])
def test_failure_stage_identifies_sku_entry_click(self) -> None:
class ClickFailureDevice(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "click":
self.calls.append(("jsonrpc", method, params, timeout))
raise TimeoutError("private device detail")
return super().jsonrpc_call(method, params, timeout)
adb = _FakeAdb()
device = ClickFailureDevice("<hierarchy />")
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE)
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionRunError) as raised:
SkuSelectionRunner(adb, lambda serial: device, 0.03).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
)
self.assertEqual(safe_failure_stage(raised.exception), "sku_entry_click")
self.assertEqual(len(_actions(device, "click")), 1)
def test_target_created_during_publish_is_preserved_without_staging_residue(self) -> None:
with TemporaryDirectory() as temporary:
target = Path(temporary) / "result"
original_rename = runner_module.os.rename
def create_target_then_rename(source: str | Path, destination: str | Path) -> None:
Path(destination).mkdir()
(Path(destination) / "sentinel").write_text("keep", encoding="utf-8")
original_rename(source, destination)
with (
patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow),
patch.object(runner_module.os, "rename", side_effect=create_target_then_rename),
self.assertRaises(SkuSelectionRunError),
):
self._runner(_FakeAdb(), _RawDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertEqual((target / "sentinel").read_text(encoding="utf-8"), "keep")
self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
def test_bad_screenshot_or_existing_target_never_publishes_manifest(self) -> None:
with TemporaryDirectory() as temporary:
target = Path(temporary) / "result"
with (
patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow),
self.assertRaises(SkuSelectionScreenshotError) as screenshot_failure,
):
self._runner(_FakeAdb(), _RawDevice(screenshot="not-image")).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertEqual(safe_failure_stage(screenshot_failure.exception), "screenshot_capture")
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
target = Path(temporary) / "write-failure"
with (
patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow),
patch.object(runner_module, "_save_base64_screenshot", side_effect=OSError("private path")),
):
with self.assertRaises(SkuSelectionScreenshotError):
self._runner(_FakeAdb(), _RawDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".write-failure.staging-*")), [])
adb = _FakeAdb()
device = _RawDevice()
target.mkdir()
sentinel = target / "keep"
sentinel.write_text("keep", encoding="utf-8")
with self.assertRaises(SkuSelectionRunError) as existing_target_failure:
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertEqual(safe_failure_stage(existing_target_failure.exception), "precheck")
self.assertEqual(adb.calls, [])
self.assertEqual(device.calls, [])
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
def test_device_screen_and_output_preflight_fail_before_any_click(self) -> None:
with TemporaryDirectory() as temporary:
adb = _FakeAdb()
adb.inspection = DeviceInspection(AdbDevice(serial="device-1", state="device"), "wrong", "16")
device = _RawDevice()
with self.assertRaises(SkuSelectionRunError):
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
self.assertEqual(device.calls, [])
class WrongScreenDevice(_RawDevice):
def window_size(self) -> tuple[int, int]:
return 1080, 1920
device = WrongScreenDevice()
with self.assertRaises(SkuSelectionRunError):
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "screen")
self.assertEqual(_actions(device, "click"), [])
parent_file = Path(temporary) / "not-a-directory"
parent_file.write_text("x", encoding="utf-8")
adb = _FakeAdb()
device = _RawDevice()
with self.assertRaises(SkuSelectionRunError):
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, parent_file / "result")
self.assertEqual(adb.calls, [])
self.assertEqual(device.calls, [])
def test_small_but_valid_png_is_not_accepted(self) -> None:
image = Image.new("RGB", (1, 1), "white")
raw = BytesIO(); image.save(raw, format="PNG")
with (
TemporaryDirectory() as temporary,
patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow),
self.assertRaises(SkuSelectionScreenshotError),
):
self._runner(_FakeAdb(), _RawDevice(screenshot=base64.b64encode(raw.getvalue()).decode("ascii"))).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
)
def test_failure_after_entry_attempts_one_safe_exit_and_hides_device_detail(self) -> None:
adb = _FakeAdb()
device = _RawDevice()
device.fail_color_readback = True
device.select_alternates()
with TemporaryDirectory() as temporary:
with self.assertRaises(SkuSelectionError):
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
self.assertEqual(_actions(device, "pressKey"), [])
class FailingRawDevice(_RawDevice):
def app_info(self, package_name: str) -> dict[str, str]:
raise RuntimeError("device-1 <xml>private</xml>")
with self.assertRaises(SkuSelectionDeviceAdapterError) as raised:
UiautomatorSkuPanelAdapter(FailingRawDevice(), 10).app_info("com.xunmeng.pinduoduo")
self.assertNotIn("device-1", str(raised.exception))
self.assertNotIn("private", str(raised.exception))
def test_unverified_failure_never_sends_blind_back(self) -> None:
class InvalidAfterOptionDevice(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
value = super().jsonrpc_call(method, params, timeout)
if method == "click" and "[396,498][895,570]" in self.hierarchy:
self.hierarchy = "<hierarchy />"
return value
device = InvalidAfterOptionDevice()
device.select_alternates()
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError):
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
self.assertEqual(_actions(device, "pressKey"), [])
def test_adapter_timeout_is_mapped_without_third_party_detail(self) -> None:
class TimeoutRawDevice(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
raise TimeoutError("device-1 <hierarchy>private</hierarchy>")
with self.assertRaises(SkuSelectionRunError) as raised:
UiautomatorSkuPanelAdapter(TimeoutRawDevice(), 10).dump_window_hierarchy()
self.assertNotIn("device-1", str(raised.exception))
self.assertNotIn("private", str(raised.exception))
def test_entry_attempt_is_recorded_before_unconfirmed_click_and_not_retried(self) -> None:
class TimeoutTapDevice(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
self.calls.append(("jsonrpc", method, params, timeout))
if method == "click":
raise TimeoutError("device detail")
return super().jsonrpc_call(method, params, timeout)
adapter = UiautomatorSkuPanelAdapter(TimeoutTapDevice(), 10)
with self.assertRaises(SkuSelectionRunError):
adapter.tap_sku_entry("[688,2184][1042,2253]")
self.assertTrue(adapter.entry_was_tapped)
self.assertEqual(_actions(adapter._device, "click"), [("jsonrpc", "click", [865, 2218], 10)])
def test_adapter_seals_every_named_mutation_before_rpc(self) -> None:
device = _RawDevice(_PRODUCT_PAGE)
adapter = UiautomatorSkuPanelAdapter(device, 10)
adapter.tap_sku_entry("[688,2184][1042,2253]")
with self.assertRaises(SkuSelectionDeviceAdapterError):
adapter.tap_sku_entry("[688,2184][1042,2253]")
adapter.tap_sku_option("[372,1188][684,1587]")
with self.assertRaises(SkuSelectionDeviceAdapterError):
adapter.tap_sku_option("[372,1188][684,1587]")
adapter.reveal_size_options_once()
with self.assertRaises(SkuSelectionDeviceAdapterError):
adapter.reveal_size_options_once()
adapter.leave_sku_panel()
with self.assertRaises(SkuSelectionDeviceAdapterError):
adapter.leave_sku_panel()
self.assertEqual(len(_actions(device, "click")), 2)
self.assertEqual(len(_actions(device, "swipe")), 1)
self.assertEqual(len(_actions(device, "pressKey")), 1)
self.assertEqual(adapter.option_attempts, 1)
self.assertEqual(adapter.entry_rpc_outcome, "completed")
self.assertEqual(adapter.reveal_rpc_outcome, "completed")
self.assertEqual(adapter.back_attempts, 1)
self.assertEqual(adapter.back_rpc_outcome, "completed")
def test_entry_stability_interruptions_never_click(self) -> None:
now = [0.0]
class SequenceDevice(_RawDevice):
def __init__(self) -> None:
super().__init__(); self.frames = [
_dynamic_product_page("first"),
"<hierarchy />",
_dynamic_product_page("second"),
]
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "dumpWindowHierarchy" and self.frames:
self.hierarchy = self.frames.pop(0)
return super().jsonrpc_call(method, params, timeout)
device = SequenceDevice()
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10), .02, .01, lambda: now[0], lambda x: now.__setitem__(0, now[0] + x))
with self.assertRaises(SkuSelectionError): flow.open_sku_panel(_TARGET_URL, "<hierarchy />")
self.assertEqual(_actions(device, "click"), [])
def test_screenshot_drift_and_foreground_drift_publish_nothing_and_never_back(self) -> None:
for drift in ("color", "size", "price"):
class DriftDevice(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
value = super().jsonrpc_call(method, params, timeout)
if method == "takeScreenshot":
if drift == "price":
self.hierarchy = self.hierarchy.replace("快卖完 ¥12.88", "快卖完 ¥13.88")
else:
root = ElementTree.fromstring(self.hierarchy)
if drift == "color":
for node in root.iter("node"):
if node.get("selected") is not None and ",1000]" in node.get("bounds", ""):
node.set("selected", "false")
next(node for node in root.iter("node") if node.get("content-desc") == "粉红").set("selected", "true")
else:
for node in root.iter("node"):
if node.get("selected") is not None and ",1730]" in node.get("bounds", ""):
node.set("selected", "false")
next(node for node in root.iter("node") if node.get("text") == "L(建议115-130)").set("selected", "true")
self.hierarchy = ElementTree.tostring(root, encoding="unicode")
return value
with self.subTest(drift=drift), TemporaryDirectory() as temporary:
target = Path(temporary) / "out"
with self.assertRaises((SkuSelectionError, SkuSelectionRunError)):
self._runner(_FakeAdb(), DriftDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertFalse(target.exists())
self.assertFalse((target / "manifest.json").exists())
self.assertEqual(list(Path(temporary).glob(".out.staging-*")), [])
device = _RawDevice(); device.select_alternates()
device.package = "other"
with self.assertRaises(SkuSelectionError): _flow(device).exit_sku_panel_safely()
self.assertEqual(_actions(device, "pressKey"), [])
def test_back_precondition_rejects_every_non_target_profile_after_screenshot_reverify(self) -> None:
drifts = (
_CURRENT_ONLY_EMPTY_FIXTURE.read_text(encoding="utf-8"),
_CURRENT_ONLY_COLOR_FIXTURE.read_text(encoding="utf-8"),
_S_FIXTURE.read_text(encoding="utf-8"),
_revealed_current_only(),
)
for drift in drifts:
class DriftBeforeBackDevice(_RawDevice):
def __init__(self) -> None:
super().__init__()
self.after_screenshot_reads = 0
self.screenshot_seen = False
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "takeScreenshot":
value = super().jsonrpc_call(method, params, timeout)
self.screenshot_seen = True
return value
if method == "dumpWindowHierarchy" and self.screenshot_seen:
self.after_screenshot_reads += 1
if self.after_screenshot_reads == 2:
self.hierarchy = drift
return super().jsonrpc_call(method, params, timeout)
with self.subTest(profile=_classify_panel(_parse_nodes(drift)).profile), TemporaryDirectory() as temporary:
device = DriftBeforeBackDevice()
target = Path(temporary) / "result"
with self.assertRaises(SkuSelectionError) as raised:
self._runner(_FakeAdb(), device).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target
)
self.assertEqual(safe_failure_stage(raised.exception), "safe_exit")
self.assertEqual(_actions(device, "pressKey"), [])
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
def test_dual_precondition_publishes_nothing_without_reveal_or_back(self) -> None:
device = _RawDevice()
device.panel_hierarchy = _EMPTY_FIXTURE.read_text(encoding="utf-8")
device.color_hierarchy = _COLOR_FIXTURE.read_text(encoding="utf-8")
with TemporaryDirectory() as temporary:
target = Path(temporary) / "out"
with self.assertRaises(SkuSelectionError):
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertFalse(target.exists())
self.assertFalse((target / "manifest.json").exists())
self.assertEqual(list(Path(temporary).glob(".out.staging-*")), [])
self.assertEqual(_actions(device, "swipe"), [])
self.assertEqual(len(_actions(device, "pressKey")), 0)
def test_option_timeout_reconciliation_controls_back_once(self) -> None:
class OptionTimeoutDevice(_RawDevice):
def __init__(self, delivered: bool) -> None:
super().__init__(); self.delivered = delivered; self.clicks = 0
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "click":
self.clicks += 1
if self.clicks == 2:
if self.delivered: super().jsonrpc_call(method, params, timeout)
else: self.calls.append(("jsonrpc", method, params, timeout))
raise TimeoutError("uncertain option")
return super().jsonrpc_call(method, params, timeout)
for delivered, expected_back in ((False, 0), (True, 0)):
with self.subTest(delivered=delivered), TemporaryDirectory() as temporary:
device = OptionTimeoutDevice(delivered)
adb = _FakeAdb(); device.hierarchy = "<hierarchy />"
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE.replace("<hierarchy>", '<hierarchy post-intent="1">'))
runner = SkuSelectionRunner(adb, lambda serial: device, .03)
with self.assertRaises(SkuSelectionRunError):
runner.run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "out")
self.assertEqual(len(_actions(device, "click")), 2)
self.assertEqual(len(_actions(device, "pressKey")), expected_back)
def test_back_timeout_is_never_retried(self) -> None:
class BackTimeoutDevice(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "pressKey":
super().jsonrpc_call(method, params, timeout)
raise TimeoutError("back uncertain")
return super().jsonrpc_call(method, params, timeout)
device = BackTimeoutDevice(_M_FIXTURE.read_text(encoding="utf-8"))
with self.assertRaises(SkuSelectionRunError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 0.03)).exit_sku_panel_safely()
self.assertEqual(len(_actions(device, "pressKey")), 1)
def test_entry_click_timeout_reconciles_only_through_verified_flow_exit(self) -> None:
class DeliveredThenTimeout(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "click" and self.hierarchy != _FIXTURE.read_text(encoding="utf-8"):
super().jsonrpc_call(method, params, timeout)
raise TimeoutError("delivery uncertain")
return super().jsonrpc_call(method, params, timeout)
device = DeliveredThenTimeout()
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionRunError):
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
self.assertEqual(len(_actions(device, "click")), 1)
self.assertEqual(len(_actions(device, "pressKey")), 0)
class _ExitEvidenceDevice(_RawDevice):
def __init__(self, hierarchy: str | None = None) -> None:
super().__init__(hierarchy or _M_FIXTURE.read_text(encoding="utf-8"))
self.activity = "com.xunmeng.pinduoduo.activity.NewPageActivity"
def app_current(self) -> dict[str, str]:
self.calls.append(("app_current",))
return {"package": self.package, "activity": self.activity}
class SkuExitSpikeCapturerTests(unittest.TestCase):
def _capturer(self, adb: _FakeAdb, device: _ExitEvidenceDevice) -> SkuExitSpikeCapturer:
return SkuExitSpikeCapturer(adb, lambda serial: device, 0.03)
def test_completed_back_atomically_publishes_human_review_only_evidence(self) -> None:
adb = _FakeAdb()
device = _ExitEvidenceDevice()
with TemporaryDirectory() as temporary:
target = Path(temporary) / "exit-evidence"
result = self._capturer(adb, device).capture("192.168.0.173:5555", target)
manifest_text = result.manifest_path.read_text(encoding="utf-8")
manifest = json.loads(manifest_text)
self.assertEqual(result.output_directory, target)
self.assertEqual(manifest["product"], {"goods_id": "937122477375"})
self.assertEqual(manifest["channel"], "wifi")
self.assertEqual(manifest["back_attempts"], 1)
self.assertEqual(manifest["rpc_outcome"], "completed")
self.assertEqual(manifest["post_exit_status"], "human_review_required")
self.assertNotIn("safe_exit", manifest_text)
self.assertNotIn("SKU_PANEL_GATE_1", manifest_text)
self.assertNotIn("192.168.0.173:5555", manifest_text)
self.assertEqual(
[item["path"] for item in manifest["artifacts"]],
[
"post_exit_screenshot.png",
"post_exit_hierarchy.xml",
"post_exit_app.json",
],
)
self.assertEqual(
manifest["artifacts"][0]["role"],
"post_exit_human_review_only",
)
for artifact in manifest["artifacts"]:
self.assertTrue((target / artifact["path"]).is_file())
self.assertRegex(artifact["sha256"], r"^[0-9a-f]{64}$")
self.assertEqual(
json.loads((target / "post_exit_app.json").read_text(encoding="utf-8")),
{
"activity": "com.xunmeng.pinduoduo.activity.NewPageActivity",
"package": "com.xunmeng.pinduoduo",
},
)
self.assertEqual(adb.calls, [("inspect", "192.168.0.173:5555")])
self.assertEqual(len(_actions(device, "pressKey")), 1)
self.assertEqual(len(_actions(device, "takeScreenshot")), 1)
self.assertEqual(_actions(device, "click"), [])
self.assertEqual(_actions(device, "swipe"), [])
def test_precondition_mismatch_or_drift_never_sends_back_or_leaves_artifacts(self) -> None:
class WrongScreen(_ExitEvidenceDevice):
def window_size(self) -> tuple[int, int]:
self.calls.append(("window_size",))
return 1080, 1920
class HierarchyDrift(_ExitEvidenceDevice):
def __init__(self) -> None:
super().__init__()
self.reads = 0
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "dumpWindowHierarchy":
self.reads += 1
if self.reads == 2:
self.hierarchy = _S_FIXTURE.read_text(encoding="utf-8")
return super().jsonrpc_call(method, params, timeout)
cases: list[tuple[str, _ExitEvidenceDevice]] = []
wrong_version = _ExitEvidenceDevice(); wrong_version.version = "8.18.0"
wrong_foreground = _ExitEvidenceDevice(); wrong_foreground.package = "other.package"
wrong_price = _ExitEvidenceDevice(); wrong_price.hierarchy = wrong_price.hierarchy.replace("快卖完 ¥12.88", "快卖完 ¥13.88")
cases.extend(
(
("version", wrong_version),
("foreground", wrong_foreground),
("screen", WrongScreen()),
("profile", _ExitEvidenceDevice(_S_FIXTURE.read_text(encoding="utf-8"))),
("price", wrong_price),
("drift", HierarchyDrift()),
)
)
for name, device in cases:
with self.subTest(name=name), TemporaryDirectory() as temporary:
target = Path(temporary) / "exit-evidence"
with self.assertRaises((SkuSelectionError, SkuSelectionRunError)):
self._capturer(_FakeAdb(), device).capture("device-1", target)
self.assertEqual(_actions(device, "pressKey"), [])
self.assertEqual(_actions(device, "takeScreenshot"), [])
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".exit-evidence.staging-*")), [])
def test_ambiguous_back_delivered_or_undelivered_publishes_once_for_human_review(self) -> None:
class AmbiguousBack(_ExitEvidenceDevice):
def __init__(self, delivered: bool) -> None:
super().__init__()
self.delivered = delivered
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "pressKey":
self.calls.append(("jsonrpc", method, params, timeout))
if self.delivered:
self.hierarchy = "<hierarchy rotation=\"0\" />"
raise TimeoutError("private RPC detail")
return super().jsonrpc_call(method, params, timeout)
for delivered in (True, False):
with self.subTest(delivered=delivered), TemporaryDirectory() as temporary:
device = AmbiguousBack(delivered)
target = Path(temporary) / "exit-evidence"
result = self._capturer(_FakeAdb(), device).capture("device-1", target)
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
self.assertEqual(manifest["back_attempts"], 1)
self.assertEqual(manifest["rpc_outcome"], "ambiguous_reconciled")
self.assertEqual(manifest["post_exit_status"], "human_review_required")
self.assertEqual(len(_actions(device, "pressKey")), 1)
self.assertEqual(len(_actions(device, "takeScreenshot")), 1)
def test_non_pdd_post_app_is_rejected_before_screenshot_for_completed_and_ambiguous_back(self) -> None:
class PostAppDrift(_ExitEvidenceDevice):
def __init__(self, ambiguous: bool) -> None:
super().__init__()
self.ambiguous = ambiguous
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "pressKey":
self.calls.append(("jsonrpc", method, params, timeout))
self.package = "external.app"
if self.ambiguous:
raise TimeoutError("private RPC detail")
return ""
return super().jsonrpc_call(method, params, timeout)
for ambiguous in (False, True):
with self.subTest(ambiguous=ambiguous), TemporaryDirectory() as temporary:
device = PostAppDrift(ambiguous)
target = Path(temporary) / "exit-evidence"
with self.assertRaises(SkuExitSpikeError):
self._capturer(_FakeAdb(), device).capture("device-1", target)
self.assertEqual(len(_actions(device, "pressKey")), 1)
self.assertEqual(_actions(device, "takeScreenshot"), [])
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".exit-evidence.staging-*")), [])
class PostVersionDrift(_ExitEvidenceDevice):
def __init__(self) -> None:
super().__init__()
self.version_reads = 0
def app_info(self, package_name: str) -> dict[str, str]:
self.version_reads += 1
if self.version_reads == 3:
return {"versionName": "8.18.0"}
return super().app_info(package_name)
class MissingPostActivity(_ExitEvidenceDevice):
def app_current(self) -> dict[str, str]:
value = super().app_current()
if _actions(self, "pressKey"):
value.pop("activity")
return value
for name, device in (("version", PostVersionDrift()), ("app_summary", MissingPostActivity())):
with self.subTest(name=name), TemporaryDirectory() as temporary:
target = Path(temporary) / "exit-evidence"
with self.assertRaises(SkuSelectionRunError):
self._capturer(_FakeAdb(), device).capture("device-1", target)
self.assertEqual(len(_actions(device, "pressKey")), 1)
self.assertEqual(_actions(device, "takeScreenshot"), [])
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".exit-evidence.staging-*")), [])
def test_post_capture_app_drift_or_read_failures_clean_every_artifact_without_retry(self) -> None:
class AppDrift(_ExitEvidenceDevice):
def __init__(self) -> None:
super().__init__()
self.post_reads = 0
def app_current(self) -> dict[str, str]:
value = super().app_current()
if _actions(self, "pressKey"):
self.post_reads += 1
if self.post_reads == 2:
value["activity"] = "com.xunmeng.pinduoduo.activity.OtherActivity"
return value
class BadXml(_ExitEvidenceDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "dumpWindowHierarchy" and _actions(self, "pressKey"):
self.calls.append(("jsonrpc", method, params, timeout))
return "not-xml"
return super().jsonrpc_call(method, params, timeout)
class ScreenshotTimeout(_ExitEvidenceDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "takeScreenshot":
self.calls.append(("jsonrpc", method, params, timeout))
raise TimeoutError("private RPC detail")
return super().jsonrpc_call(method, params, timeout)
bad_png = _ExitEvidenceDevice(); bad_png.screenshot = "not-image"
for name, device in (
("app_drift", AppDrift()),
("bad_xml", BadXml()),
("bad_png", bad_png),
("screenshot_timeout", ScreenshotTimeout()),
):
with self.subTest(name=name), TemporaryDirectory() as temporary:
target = Path(temporary) / "exit-evidence"
with self.assertRaises(SkuSelectionRunError):
self._capturer(_FakeAdb(), device).capture("device-1", target)
self.assertEqual(len(_actions(device, "pressKey")), 1)
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".exit-evidence.staging-*")), [])
def test_existing_target_publish_race_preseal_failure_and_repeat_are_fail_closed(self) -> None:
with TemporaryDirectory() as temporary:
target = Path(temporary) / "existing"
target.mkdir()
sentinel = target / "keep"
sentinel.write_text("keep", encoding="utf-8")
adb = _FakeAdb(); device = _ExitEvidenceDevice()
with self.assertRaises(SkuSelectionRunError):
self._capturer(adb, device).capture("device-1", target)
self.assertEqual(adb.calls, [])
self.assertEqual(device.calls, [])
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
race_target = Path(temporary) / "race"
original_rename = runner_module.os.rename
def create_target_then_rename(source: str | Path, destination: str | Path) -> None:
Path(destination).mkdir()
(Path(destination) / "sentinel").write_text("keep", encoding="utf-8")
original_rename(source, destination)
race_device = _ExitEvidenceDevice()
with (
patch.object(runner_module.os, "rename", side_effect=create_target_then_rename),
self.assertRaises(SkuSelectionRunError),
):
self._capturer(_FakeAdb(), race_device).capture("device-1", race_target)
self.assertEqual((race_target / "sentinel").read_text(encoding="utf-8"), "keep")
self.assertEqual(list(Path(temporary).glob(".race.staging-*")), [])
self.assertEqual(len(_actions(race_device, "pressKey")), 1)
preseal_target = Path(temporary) / "preseal"
preseal_device = _ExitEvidenceDevice()
with (
patch.object(UiautomatorSkuExitAdapter, "leave_sku_panel", side_effect=SkuSelectionRunError("private")),
self.assertRaises(SkuSelectionRunError),
):
self._capturer(_FakeAdb(), preseal_device).capture("device-1", preseal_target)
self.assertEqual(_actions(preseal_device, "pressKey"), [])
self.assertFalse(preseal_target.exists())
self.assertEqual(list(Path(temporary).glob(".preseal.staging-*")), [])
repeated_device = _ExitEvidenceDevice()
capturer = self._capturer(_FakeAdb(), repeated_device)
capturer.capture("device-1", Path(temporary) / "once")
with self.assertRaises(SkuExitSpikeError):
capturer.capture("device-1", Path(temporary) / "twice")
self.assertEqual(len(_actions(repeated_device, "pressKey")), 1)
def test_static_t104_boundary_has_only_named_back_and_read_calls(self) -> None:
root = Path(__file__).resolve().parents[2]
runner_path = root / "src" / "cmbuyer_client" / "pdd" / "sku_selection_runner.py"
script_path = root / "scripts" / "capture_sku_exit_spike.py"
runner_tree = ast.parse(runner_path.read_text(encoding="utf-8"))
script_source = script_path.read_text(encoding="utf-8")
script_tree = ast.parse(script_source)
classes = {
node.name: node
for node in runner_tree.body
if isinstance(node, ast.ClassDef)
}
selected = (classes["UiautomatorSkuExitAdapter"], classes["SkuExitSpikeCapturer"])
called_attributes = {
node.func.attr
for selected_class in selected
for node in ast.walk(selected_class)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
}
forbidden_calls = {
"start_pdd_view_intent",
"tap_sku_entry",
"tap_sku_option",
"reveal_size_options_once",
"set_quantity_and_readback",
"go_to_order_confirm",
"create_submission_fence",
"submit_order_once",
}
self.assertTrue(forbidden_calls.isdisjoint(called_attributes))
self.assertTrue(forbidden_calls.isdisjoint(
node.attr for node in ast.walk(script_tree) if isinstance(node, ast.Attribute)
))
self.assertTrue(
{"click", "swipe", "scroll", "intent", "quantity", "confirm", "fence", "submit", "payment"}.isdisjoint(
script_source.lower().split()
)
)
public_adapter_api = {
name for name in UiautomatorSkuExitAdapter.__dict__ if not name.startswith("_")
}
self.assertEqual(
public_adapter_api,
{
"app_info",
"app_current",
"dump_window_hierarchy",
"capture_screenshot",
"display_size",
"leave_sku_panel",
"back_attempts",
"back_rpc_outcome",
},
)
class SkuSelectionCliTests(unittest.TestCase):
def test_cli_accepts_only_target_url_and_task_values(self) -> None:
script = _load_runner_script()
valid = {
"serial": "device-1",
"url": _TARGET_URL,
"color": _TASK_COLOR,
"size": _TASK_SIZE,
"output_dir": Path("evidence"),
"timeout": 10.0,
"adb": "adb",
}
script.validate_arguments(type("Arguments", (), valid)())
for field, value in (("serial", ""), ("url", "https://mobile.yangkeduo.com/goods.html?goods_id=1"), ("color", "黑色 CHA (纯棉)"), ("size", "M(建议100-115)"), ("timeout", 0), ("timeout", float("inf"))):
with self.subTest(field=field, value=value), self.assertRaises((ValueError, SkuSelectionError)):
script.validate_arguments(type("Arguments", (), valid | {field: value})())
def test_cli_main_catches_flow_error_without_traceback_or_page_body(self) -> None:
script = _load_runner_script()
secret = "SERIAL=192.168.0.173:5555 PATH=C:/private <hierarchy>page-body</hierarchy>"
class HostileGetterError(SkuSelectionError):
def __getattribute__(self, name: str) -> object:
if name == "_cmbuyer_failure_stage":
raise RuntimeError(secret)
return super().__getattribute__(name)
class HostileStage(str):
def __hash__(self) -> int:
raise RuntimeError(secret)
hostile_string = SkuSelectionError(secret)
setattr(hostile_string, "_cmbuyer_failure_stage", HostileStage("sku_entry"))
for failure in (SkuSelectionError(secret), HostileGetterError(secret), hostile_string):
class FlowFailingRunner:
def __init__(self, *args: object, **kwargs: object) -> None: pass
def run(self, *args: object, **kwargs: object) -> object:
raise failure
stderr = BytesIO()
# TextIOWrapper keeps the assertion independent from host console encoding.
import io
text_stderr = io.TextIOWrapper(stderr, encoding="utf-8")
with patch.object(script, "SkuSelectionRunner", FlowFailingRunner), redirect_stderr(text_stderr):
status = script.main([
"--serial", "device-1", "--url", _TARGET_URL, "--color", _TASK_COLOR,
"--size", _TASK_SIZE, "--output-dir", "evidence",
])
text_stderr.flush()
output = stderr.getvalue().decode("utf-8")
self.assertEqual(status, 1)
self.assertIn("stage=unknown", output)
self.assertNotIn("Traceback", output)
self.assertNotIn(secret, output)
self.assertNotIn("page-body", output)
class SkuExitSpikeCliTests(unittest.TestCase):
def test_cli_accepts_only_explicit_device_output_timeout_and_adb(self) -> None:
script = _load_exit_script()
arguments = script.parse_arguments(
[
"--serial", "device-1",
"--output-dir", "fresh-evidence",
"--timeout", "10",
"--adb", "adb.exe",
]
)
script.validate_arguments(arguments)
self.assertEqual(
set(vars(arguments)),
{"serial", "output_dir", "timeout", "adb"},
)
for field, value in (("serial", ""), ("timeout", 0), ("timeout", float("inf"))):
with self.subTest(field=field), self.assertRaises(ValueError):
script.validate_arguments(
type(
"Arguments",
(),
{
"serial": "device-1",
"output_dir": Path("fresh-evidence"),
"timeout": 10.0,
"adb": "adb",
field: value,
},
)()
)
with redirect_stderr(StringIO()), self.assertRaises(SystemExit):
script.parse_arguments(
[
"--serial", "device-1",
"--output-dir", "fresh-evidence",
"--goods-id", "937122477375",
]
)
def test_cli_never_echoes_serial_page_text_or_sensitive_path(self) -> None:
script = _load_exit_script()
secret = "SERIAL=192.168.0.173:5555 PATH=C:/Users/private <hierarchy>private</hierarchy>"
class FailingCapturer:
def __init__(self, *args: object, **kwargs: object) -> None:
pass
def capture(self, *args: object, **kwargs: object) -> object:
raise SkuExitSpikeError(secret)
stderr = BytesIO()
import io
text_stderr = io.TextIOWrapper(stderr, encoding="utf-8")
with patch.object(script, "SkuExitSpikeCapturer", FailingCapturer), redirect_stderr(text_stderr):
status = script.main(
[
"--serial", "192.168.0.173:5555",
"--output-dir", "C:/Users/private/evidence",
]
)
text_stderr.flush()
output = stderr.getvalue().decode("utf-8")
self.assertEqual(status, 1)
self.assertNotIn(secret, output)
self.assertNotIn("192.168.0.173:5555", output)
self.assertNotIn("C:/Users/private", output)
self.assertNotIn("Traceback", output)
class SuccessfulCapturer:
def __init__(self, *args: object, **kwargs: object) -> None:
pass
def capture(self, *args: object, **kwargs: object) -> object:
return object()
stdout = BytesIO()
text_stdout = io.TextIOWrapper(stdout, encoding="utf-8")
with patch.object(script, "SkuExitSpikeCapturer", SuccessfulCapturer), redirect_stdout(text_stdout):
status = script.main(
[
"--serial", "192.168.0.173:5555",
"--output-dir", "C:/Users/private/evidence",
]
)
text_stdout.flush()
output = stdout.getvalue().decode("utf-8")
self.assertEqual(status, 0)
self.assertNotIn("192.168.0.173:5555", output)
self.assertNotIn("C:/Users/private", output)
def _load_runner_script() -> object:
path = Path(__file__).resolve().parents[2] / "scripts" / "run_t103_sku_selection.py"
specification = importlib.util.spec_from_file_location("run_t103_sku_selection_test", path)
if specification is None or specification.loader is None:
raise RuntimeError("无法加载 T-103 运行脚本。")
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)
return module
def _load_exit_script() -> object:
path = Path(__file__).resolve().parents[2] / "scripts" / "capture_sku_exit_spike.py"
specification = importlib.util.spec_from_file_location("capture_sku_exit_spike_test", path)
if specification is None or specification.loader is None:
raise RuntimeError("无法加载 T-104 阶段 A 脚本。")
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)
return module
if __name__ == "__main__":
unittest.main()