fix(client): stabilize verified sku entry

This commit is contained in:
QiuSW
2026-08-05 10:10:56 +08:00
parent 42eac57824
commit a09c58beef
2 changed files with 349 additions and 33 deletions
+218 -1
View File
@@ -143,6 +143,86 @@ def _duplicate_entry() -> str:
return ElementTree.tostring(root, encoding="unicode")
def _extra_entry_action_ancestor() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
action = _entry_chain(root)[4]
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)[4].set("content-desc", action_desc)
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 = "[10,1260][100,1300]",
) -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
action = _entry_chain(root)[4]
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 _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]
@@ -202,6 +282,73 @@ class SkuSelectionFlowTests(unittest.TestCase):
self.assertEqual(_tap_centers(device), [(978, 1333)])
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), [(978, 1333)])
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()
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(
_TARGET_URL,
_home_with_unverified_entry_labels(),
)
self.assertEqual(_tap_centers(device), [(978, 1333)])
def test_entry_child_and_every_ancestor_attribute_drift_never_clicks(self) -> None:
expected_clickable = ("false", "false", "false", "false", "true")
for depth in range(5):
@@ -217,8 +364,74 @@ class SkuSelectionFlowTests(unittest.TestCase):
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(depth, "text", "祖先文字漂移") for depth in range(1, 5)
] + [
_mutate_entry(depth, "content-desc", "祖先描述漂移") for depth in range(0, 4)
] + [
_mutate_entry(4, "content-desc", "祖先描述漂移")
] + [
_mutate_entry(4, "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",
"[950,1300][1000,1340]",
),
_with_overlapping_clickable(
"com.android.systemui",
"android.view.ViewGroup",
"[936,1208][1080,1352]",
),
_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(4, "content-desc", "立即购买快要抢光"),
):
with self.subTest():
device = _RawDevice()
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10))
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"))
@@ -555,7 +768,11 @@ class SkuSelectionRunnerTests(unittest.TestCase):
now = [0.0]
class SequenceDevice(_RawDevice):
def __init__(self) -> None:
super().__init__(); self.frames = [_PRODUCT_PAGE, "<hierarchy />", _PRODUCT_PAGE]
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)