fix(client): target verified bottom sku entry

This commit is contained in:
QiuSW
2026-08-05 10:56:55 +08:00
parent 81a44caeef
commit 2f1380d084
3 changed files with 148 additions and 85 deletions
+63 -51
View File
@@ -16,12 +16,15 @@ EXPECTED_UNIT_PRICE = "12.88"
# 任务值不是页面判据;右侧是 v5 取证的唯一 accessibility 文案(空格/全角括号均有意义)。 # 任务值不是页面判据;右侧是 v5 取证的唯一 accessibility 文案(空格/全角括号均有意义)。
TASK_TO_UI_SELECTION = {("黑色CHA(纯棉)", "M(建议100-115)"): ("黑色 CHA (纯棉)", "M(建议100-115)")} TASK_TO_UI_SELECTION = {("黑色CHA(纯棉)", "M(建议100-115)"): ("黑色 CHA (纯棉)", "M(建议100-115)")}
_TARGET_COLOR_UI, _TARGET_SIZE_UI = next(iter(TASK_TO_UI_SELECTION.values())) _TARGET_COLOR_UI, _TARGET_SIZE_UI = next(iter(TASK_TO_UI_SELECTION.values()))
_ENTRY = "快要抢光" _ENTRY = "快要抢光 ¥ 12.88"
_ENTRY_TEXT_BOUNDS = "[900,1312][1056,1355]" _ENTRY_PROMOTION_LABEL = "快要抢光"
_ENTRY_INNER_BOUNDS = "[712,1312][1056,1355]" _ENTRY_TEXT_BOUNDS = "[688,2184][1042,2253]"
_ENTRY_ACTION_BOUNDS = "[0,1256][1080,1355]" _ENTRY_ACTION_DESC = "快要抢光¥12.88"
_ENTRY_ACTION_BOUNDS = "[446,2166][1080,2328]"
_ENTRY_SIBLING = "免拼购买"
_ENTRY_SIBLING_BOUNDS = "[688,2256][856,2305]"
_FORBIDDEN_ENTRY_ACTION_DESC = ( _FORBIDDEN_ENTRY_ACTION_DESC = (
"购买", "下单", "付款", "订单", "免拼购买", "单独购买", "直接拼成", "提交订单", "支付", "购买", "下单", "付款", "订单", "单独购买", "直接拼成", "提交订单", "支付",
"先用后付", "0元下单", "0 元下单", "先用后付", "0元下单", "0 元下单",
) )
_SIZE = "尺码" _SIZE = "尺码"
@@ -236,7 +239,8 @@ class SkuSelectionFlow:
raise SkuSelectionError("商品页规格入口不唯一,已停止操作。") raise SkuSelectionError("商品页规格入口不唯一,已停止操作。")
if len(entries) == 1 and raw != previous: if len(entries) == 1 and raw != previous:
# 商品详情正文包含倒计时等动态节点,全文 XML 稳定不是已取证入口的安全属性。 # 商品详情正文包含倒计时等动态节点,全文 XML 稳定不是已取证入口的安全属性。
# 连续两帧只比较完整五层入口判据投影;任一层失配仍会在 eligible 阶段清空稳定态。 # 连续两帧只比较证据绑定的底部入口、直接父容器和不可点击兄弟节点;商品正文及
# 父容器内其他非危险动态节点不是入口身份,不能迫使实现退回坐标兜底。
projection = _entry_projection(entries[0], nodes) projection = _entry_projection(entries[0], nodes)
if projection is None: if projection is None:
raise SkuSelectionError("商品页规格入口结构失效,已停止操作。") raise SkuSelectionError("商品页规格入口结构失效,已停止操作。")
@@ -423,19 +427,18 @@ def _choice(node: _Node) -> bool:
def _eligible_entries(nodes: list[_Node]) -> list[_Node]: def _eligible_entries(nodes: list[_Node]) -> list[_Node]:
# 入口文本本身不可点击:必须逐层证明它仍位于已取证的唯一可点击祖先中,但动作坐标继续 # 入口文本本身不可点击:必须证明它仍是已取证底部父容器的直接子节点,但动作坐标继续
# 使用文本子节点的窄 bounds,避免把同一祖先内未知区域变成坐标兜底。“免拼购买”等底部 # 使用第一行文本的窄 bounds,避免把父容器中心或第二行“免拼购买”变成坐标兜底。
# 容器既不属于这条祖先链,也绝不能作为替代入口。
if any(node.bounds == _PRICE_PARENT for node in nodes): if any(node.bounds == _PRICE_PARENT for node in nodes):
return [] return []
entries = _physical_entries(nodes) entries = _physical_entries(nodes)
if len(entries) != 1: if len(entries) != 1:
return entries return entries
entry = entries[0] entry = entries[0]
chain = _physical_entry_chain(entry) chain = _physical_entry_chain(entry, nodes)
if chain is None or _entry_projection(entry, nodes) is None: if chain is None or _entry_projection(entry, nodes) is None:
return [] return []
ancestor = chain[-1] ancestor = chain[1]
left, top, right, bottom = _action_bounds(entry.bounds) left, top, right, bottom = _action_bounds(entry.bounds)
center = (left + (right - left) // 2, top + (bottom - top) // 2) center = (left + (right - left) // 2, top + (bottom - top) // 2)
occupants = _live_clickables_covering(nodes, center) occupants = _live_clickables_covering(nodes, center)
@@ -451,49 +454,51 @@ def _physical_entries(nodes: list[_Node]) -> list[_Node]:
and node.element.get("package") == PDD_PACKAGE and node.element.get("package") == PDD_PACKAGE
and node.element.get("class") == "android.widget.TextView" and node.element.get("class") == "android.widget.TextView"
] ]
# 这里故意只证明物理五层链。pre-intent 必须识别旧商品,不能让禁词、浮层或其他 # 这里故意只证明物理结构。pre-intent 必须识别旧商品,不能让父容器描述或浮层等
# post-intent 安全条件把已经存在的旧入口伪装成“不存在”。 # post-intent 安全条件把已经存在的旧入口伪装成“不存在”。
return [node for node in entry_labels if _physical_entry_chain(node) is not None] return [node for node in entry_labels if _physical_entry_chain(node, nodes) is not None]
def _physical_entry_chain(node: _Node) -> tuple[_Node, ...] | None: def _physical_entry_chain(node: _Node, nodes: list[_Node]) -> tuple[_Node, ...] | None:
if node.text != _ENTRY or not _exact_entry_node( if node.text != _ENTRY or not _exact_entry_node(
node, "android.widget.TextView", _ENTRY_TEXT_BOUNDS, "false" node, "android.widget.TextView", _ENTRY_TEXT_BOUNDS, "false"
) or len(node.element) != 0: ) or len(node.element) != 0:
return None return None
inner = node.parent ancestor = node.parent
switcher = inner.parent if inner is not None else None if ancestor is None or not _exact_entry_node(
frame = switcher.parent if switcher is not None else None ancestor, "android.view.ViewGroup", _ENTRY_ACTION_BOUNDS, "true"
ancestor = frame.parent if frame is not None else None
chain = (
(node, "android.widget.TextView", _ENTRY_TEXT_BOUNDS, "false"),
(inner, "android.view.ViewGroup", _ENTRY_INNER_BOUNDS, "false"),
(switcher, "android.widget.ViewSwitcher", _ENTRY_INNER_BOUNDS, "false"),
(frame, "android.widget.FrameLayout", _ENTRY_INNER_BOUNDS, "false"),
(ancestor, "android.view.ViewGroup", _ENTRY_ACTION_BOUNDS, "true"),
)
if any(
candidate is None or not _exact_entry_node(candidate, class_name, bounds, clickable)
for candidate, class_name, bounds, clickable in chain
): ):
return None return None
return tuple(candidate for candidate, _, _, _ in chain if candidate is not None) siblings = [
candidate for candidate in nodes
if candidate.parent is ancestor
and candidate.text == _ENTRY_SIBLING
and _exact_entry_node(
candidate, "android.widget.TextView", _ENTRY_SIBLING_BOUNDS, "false"
)
and not candidate.desc
and len(candidate.element) == 0
]
if len(siblings) != 1:
return None
return node, ancestor, siblings[0]
def _entry_projection(node: _Node, nodes: list[_Node]) -> tuple[object, ...] | None: def _entry_projection(node: _Node, nodes: list[_Node]) -> tuple[object, ...] | None:
chain = _physical_entry_chain(node) chain = _physical_entry_chain(node, nodes)
if chain is None: if chain is None:
return None return None
_, inner, switcher, frame, ancestor = chain _, ancestor, sibling = chain
if node.desc: if node.desc:
return None return None
if any(candidate.text or candidate.desc for candidate in (inner, switcher, frame)): # 金额只作为这个已取证入口的不可变身份。这里既不解析也不返回它,价格闸门仍只能读取规格面板。
if ancestor.text or ancestor.desc != _ENTRY_ACTION_DESC:
return None return None
if ( sibling_mentions = [
ancestor.text candidate for candidate in nodes
or (ancestor.desc and not ancestor.desc.endswith(_ENTRY)) if _ENTRY_SIBLING in candidate.text or _ENTRY_SIBLING in candidate.desc
or any(forbidden in ancestor.desc for forbidden in _FORBIDDEN_ENTRY_ACTION_DESC) ]
): if len(sibling_mentions) != 1 or sibling_mentions[0].element is not sibling.element:
return None return None
subtree = [ subtree = [
candidate for candidate in nodes candidate for candidate in nodes
@@ -503,29 +508,36 @@ def _entry_projection(node: _Node, nodes: list[_Node]) -> tuple[object, ...] | N
forbidden in value forbidden in value
for candidate in subtree for candidate in subtree
for value in (candidate.text, candidate.desc) for value in (candidate.text, candidate.desc)
if candidate.element is not sibling.element
for forbidden in _FORBIDDEN_ENTRY_ACTION_DESC for forbidden in _FORBIDDEN_ENTRY_ACTION_DESC
): ):
return None return None
if any(
value == _ENTRY_PROMOTION_LABEL
for candidate in subtree
for value in (candidate.text, candidate.desc)
):
return None
entry_text_nodes = [candidate for candidate in subtree if candidate.text == _ENTRY] entry_text_nodes = [candidate for candidate in subtree if candidate.text == _ENTRY]
if len(entry_text_nodes) != 1 or entry_text_nodes[0].element is not node.element: if len(entry_text_nodes) != 1 or entry_text_nodes[0].element is not node.element:
return None return None
# 动作祖先整棵子树按真实层级和顺序投影。允许非危险动态正文存在,但任一结构或语义 return tuple(
# 在两帧间变化都不会点击;正文只作为不透明稳定键,不从中解析详情价。 _entry_node_projection(candidate)
return _subtree_projection(ancestor.element) for candidate in (node, ancestor, sibling)
)
def _subtree_projection(element: ElementTree.Element) -> tuple[object, ...]: def _entry_node_projection(node: _Node) -> tuple[str, ...]:
return ( return (
element.tag, node.element.tag,
element.get("package", ""), node.element.get("package", ""),
element.get("class", ""), node.element.get("class", ""),
element.get("bounds", ""), node.bounds,
element.get("clickable", ""), node.element.get("clickable", ""),
element.get("enabled", ""), node.element.get("enabled", ""),
element.get("visible-to-user", ""), node.element.get("visible-to-user", ""),
element.get("text", ""), node.text,
element.get("content-desc", ""), node.desc,
tuple(_subtree_projection(child) for child in element),
) )
@@ -1,4 +1,5 @@
<hierarchy> <hierarchy>
<!-- 商品内容区同名促销小字:它不是规格入口,保留为防误点反例。 -->
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,1256][1080,1355]" clickable="true" enabled="true" visible-to-user="true"> <node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,1256][1080,1355]" clickable="true" enabled="true" visible-to-user="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true"> <node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.ViewSwitcher" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true"> <node package="com.xunmeng.pinduoduo" class="android.widget.ViewSwitcher" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true">
@@ -8,7 +9,9 @@
</node> </node>
</node> </node>
</node> </node>
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[446,2166][1080,2328]" clickable="true" enabled="true" visible-to-user="true"> <!-- 只允许点击底部购买区第一行文本叶节点中心;第二行只用于精确结构校验。 -->
<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">
<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" />
<node text="免拼购买" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[688,2256][856,2305]" clickable="false" enabled="true" visible-to-user="true" /> <node text="免拼购买" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[688,2256][856,2305]" clickable="false" enabled="true" visible-to-user="true" />
</node> </node>
</hierarchy> </hierarchy>
+81 -33
View File
@@ -77,7 +77,11 @@ class _RawDevice:
raise AssertionError(method) raise AssertionError(method)
def _apply_tap(self, x: int, y: int) -> None: def _apply_tap(self, x: int, y: int) -> None:
if "快要抢光" in self.hierarchy and "[396,498][895,570]" not in self.hierarchy: 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 self.hierarchy = self.panel_hierarchy
return return
root = ElementTree.fromstring(self.hierarchy) root = ElementTree.fromstring(self.hierarchy)
@@ -117,11 +121,12 @@ def _center(bounds: str) -> tuple[int, int]:
def _entry_chain(root: ElementTree.Element) -> list[ElementTree.Element]: def _entry_chain(root: ElementTree.Element) -> list[ElementTree.Element]:
parents = {child: parent for parent in root.iter() for child in parent} 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") == "快要抢光") child = next(node for node in root.iter("node") if node.get("text") == "快要抢光 ¥ 12.88")
chain = [child] return [child, parents[child]]
for _ in range(4):
chain.append(parents[chain[-1]])
return chain 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: def _mutate_entry(depth: int, attribute: str, value: str) -> str:
@@ -132,20 +137,20 @@ def _mutate_entry(depth: int, attribute: str, value: str) -> str:
def _without_entry() -> str: def _without_entry() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE) root = ElementTree.fromstring(_PRODUCT_PAGE)
root.remove(_entry_chain(root)[4]) root.remove(_entry_chain(root)[1])
return ElementTree.tostring(root, encoding="unicode") return ElementTree.tostring(root, encoding="unicode")
def _duplicate_entry() -> str: def _duplicate_entry() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE) root = ElementTree.fromstring(_PRODUCT_PAGE)
entry_root = _entry_chain(root)[4] entry_root = _entry_chain(root)[1]
root.append(ElementTree.fromstring(ElementTree.tostring(entry_root, encoding="unicode"))) root.append(ElementTree.fromstring(ElementTree.tostring(entry_root, encoding="unicode")))
return ElementTree.tostring(root, encoding="unicode") return ElementTree.tostring(root, encoding="unicode")
def _extra_entry_action_ancestor() -> str: def _extra_entry_action_ancestor() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE) root = ElementTree.fromstring(_PRODUCT_PAGE)
action = _entry_chain(root)[4] action = _entry_chain(root)[1]
root.append(ElementTree.Element("node", dict(action.attrib))) root.append(ElementTree.Element("node", dict(action.attrib)))
return ElementTree.tostring(root, encoding="unicode") return ElementTree.tostring(root, encoding="unicode")
@@ -159,7 +164,7 @@ def _entry_with_panel_price_marker() -> str:
def _dynamic_product_page(value: str, action_desc: str = "") -> str: def _dynamic_product_page(value: str, action_desc: str = "") -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE) root = ElementTree.fromstring(_PRODUCT_PAGE)
root.set("dynamic-page-value", value) root.set("dynamic-page-value", value)
_entry_chain(root)[4].set("content-desc", action_desc) _entry_chain(root)[1].set("content-desc", action_desc or "快要抢光¥12.88")
return ElementTree.tostring(root, encoding="unicode") return ElementTree.tostring(root, encoding="unicode")
@@ -184,10 +189,10 @@ def _with_overlapping_clickable(package: str, class_name: str, bounds: str) -> s
def _with_action_subtree_child( def _with_action_subtree_child(
text: str, text: str,
*, *,
bounds: str = "[10,1260][100,1300]", bounds: str = "[500,2170][600,2200]",
) -> str: ) -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE) root = ElementTree.fromstring(_PRODUCT_PAGE)
action = _entry_chain(root)[4] action = _entry_chain(root)[1]
ElementTree.SubElement( ElementTree.SubElement(
action, action,
"node", "node",
@@ -204,6 +209,24 @@ def _with_action_subtree_child(
return ElementTree.tostring(root, encoding="unicode") 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: def _home_with_unverified_entry_labels() -> str:
root = ElementTree.Element("hierarchy") root = ElementTree.Element("hierarchy")
for index in range(2): for index in range(2):
@@ -273,14 +296,16 @@ class SkuSelectionFlowTests(unittest.TestCase):
self.assertEqual(flow.read_sku_unit_price(), "12.88") self.assertEqual(flow.read_sku_unit_price(), "12.88")
flow.exit_sku_panel_safely() flow.exit_sku_panel_safely()
self.assertEqual(_tap_centers(device), [(978, 1333)]) self.assertEqual(_tap_centers(device), [(865, 2218)])
self.assertEqual(_actions(device, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)]) self.assertEqual(_actions(device, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
def test_full_verified_entry_structure_taps_exact_text_child_once(self) -> None: def test_full_verified_entry_structure_taps_exact_text_child_once(self) -> None:
device = _RawDevice() device = _RawDevice()
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL) SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
self.assertEqual(_tap_centers(device), [(978, 1333)]) 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: def test_entry_stability_uses_verified_chain_projection_not_whole_xml(self) -> None:
class DynamicProductDevice(_RawDevice): class DynamicProductDevice(_RawDevice):
@@ -291,7 +316,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str: def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "dumpWindowHierarchy" and "快要抢光" in self.hierarchy: if method == "dumpWindowHierarchy" and "快要抢光" in self.hierarchy:
self.frame += 1 self.frame += 1
self.hierarchy = _dynamic_product_page(str(self.frame), "活动剩余快要抢光") self.hierarchy = _dynamic_product_page(str(self.frame))
return super().jsonrpc_call(method, params, timeout) return super().jsonrpc_call(method, params, timeout)
now = [0.0] now = [0.0]
@@ -307,7 +332,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
flow.open_sku_panel(_TARGET_URL, "<hierarchy />") flow.open_sku_panel(_TARGET_URL, "<hierarchy />")
self.assertEqual(device.frame, 2) self.assertEqual(device.frame, 2)
self.assertEqual(_tap_centers(device), [(978, 1333)]) self.assertEqual(_tap_centers(device), [(865, 2218)])
def test_entry_action_description_must_be_stable_across_frames(self) -> None: def test_entry_action_description_must_be_stable_across_frames(self) -> None:
class ChangingActionDescriptionDevice(_RawDevice): class ChangingActionDescriptionDevice(_RawDevice):
@@ -347,11 +372,11 @@ class SkuSelectionFlowTests(unittest.TestCase):
_home_with_unverified_entry_labels(), _home_with_unverified_entry_labels(),
) )
self.assertEqual(_tap_centers(device), [(978, 1333)]) self.assertEqual(_tap_centers(device), [(865, 2218)])
def test_entry_child_and_every_ancestor_attribute_drift_never_clicks(self) -> None: def test_entry_child_and_every_ancestor_attribute_drift_never_clicks(self) -> None:
expected_clickable = ("false", "false", "false", "false", "true") expected_clickable = ("false", "true")
for depth in range(5): for depth in range(2):
changes = { changes = {
"package": "other.package", "package": "other.package",
"class": "android.widget.Button", "class": "android.widget.Button",
@@ -366,13 +391,12 @@ class SkuSelectionFlowTests(unittest.TestCase):
def test_entry_chain_text_and_description_drift_never_clicks(self) -> None: def test_entry_chain_text_and_description_drift_never_clicks(self) -> None:
cases = [ cases = [
_mutate_entry(depth, "text", "祖先文字漂移") for depth in range(1, 5) _mutate_entry(1, "text", "祖先文字漂移")
] + [ ] + [
_mutate_entry(depth, "content-desc", "祖先描述漂移") for depth in range(0, 4) _mutate_entry(0, "content-desc", "祖先描述漂移"),
_mutate_entry(1, "content-desc", "祖先描述漂移"),
] + [ ] + [
_mutate_entry(4, "content-desc", "祖先描述漂移") _mutate_entry(1, "content-desc", forbidden + "快要抢光")
] + [
_mutate_entry(4, "content-desc", forbidden + "快要抢光")
for forbidden in ( for forbidden in (
"免拼购买", "单独购买", "直接拼成", "提交订单", "支付", "先用后付", "0元下单", "免拼购买", "单独购买", "直接拼成", "提交订单", "支付", "先用后付", "0元下单",
"立即购买", "确认下单", "立即付款", "订单详情", "立即购买", "确认下单", "立即付款", "订单详情",
@@ -387,12 +411,12 @@ class SkuSelectionFlowTests(unittest.TestCase):
_with_overlapping_clickable( _with_overlapping_clickable(
"com.xunmeng.pinduoduo", "com.xunmeng.pinduoduo",
"android.widget.Button", "android.widget.Button",
"[950,1300][1000,1340]", "[850,2200][900,2230]",
), ),
_with_overlapping_clickable( _with_overlapping_clickable(
"com.android.systemui", "com.android.systemui",
"android.view.ViewGroup", "android.view.ViewGroup",
"[936,1208][1080,1352]", "[800,2100][1000,2300]",
), ),
_with_overlapping_clickable( _with_overlapping_clickable(
"com.android.systemui", "com.android.systemui",
@@ -408,7 +432,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
for old_page in ( for old_page in (
_extra_entry_action_ancestor(), _extra_entry_action_ancestor(),
_entry_with_panel_price_marker(), _entry_with_panel_price_marker(),
_mutate_entry(4, "content-desc", "立即购买快要抢光"), _mutate_entry(1, "content-desc", "立即购买快要抢光"),
): ):
with self.subTest(): with self.subTest():
device = _RawDevice() device = _RawDevice()
@@ -435,6 +459,30 @@ class SkuSelectionFlowTests(unittest.TestCase):
self._assert_entry_rejected_without_click(_without_entry()) self._assert_entry_rejected_without_click(_without_entry())
self._assert_entry_rejected_without_click(_mutate_entry(0, "clickable", "true")) 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: 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)): 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): with self.subTest(color=color, size=size), self.assertRaises(SkuSelectionError):
@@ -464,7 +512,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
with self.subTest(bounds=bounds), self.assertRaises(SkuSelectionError): with self.subTest(bounds=bounds), self.assertRaises(SkuSelectionError):
_action_bounds(bounds) _action_bounds(bounds)
device = _RawDevice(_PRODUCT_PAGE.replace("[900,1312][1056,1355]", "[0,0][1081,1]")) device = _RawDevice(_PRODUCT_PAGE.replace("[688,2184][1042,2253]", "[0,0][1081,1]"))
with self.assertRaises(SkuSelectionError): with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL) SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
self.assertEqual(_actions(device, "click"), []) self.assertEqual(_actions(device, "click"), [])
@@ -477,7 +525,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
device.select_alternates() device.select_alternates()
with self.assertRaises(SkuSelectionError): with self.assertRaises(SkuSelectionError):
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE)) flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(_tap_centers(device), [(978, 1333), (282, 1086)]) self.assertEqual(_tap_centers(device), [(865, 2218), (282, 1086)])
def test_non_target_selection_restores_each_dimension_once(self) -> None: def test_non_target_selection_restores_each_dimension_once(self) -> None:
device = _RawDevice() device = _RawDevice()
@@ -487,7 +535,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE)) flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual( self.assertEqual(
_tap_centers(device), _tap_centers(device),
[(978, 1333), (282, 1086), (635, 1772)], [(865, 2218), (282, 1086), (635, 1772)],
) )
def test_price_rejects_coupon_prefix_extra_amount_and_bottom_action(self) -> None: def test_price_rejects_coupon_prefix_extra_amount_and_bottom_action(self) -> None:
@@ -549,7 +597,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
device = NoPanelAfterEntry() device = NoPanelAfterEntry()
with self.assertRaises(SkuSelectionError): with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL) SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
self.assertEqual(_tap_centers(device), [(978, 1333)]) self.assertEqual(_tap_centers(device), [(865, 2218)])
duplicate = _duplicate_entry() duplicate = _duplicate_entry()
device = _RawDevice(duplicate) device = _RawDevice(duplicate)
@@ -802,9 +850,9 @@ class SkuSelectionRunnerTests(unittest.TestCase):
adapter = UiautomatorSkuPanelAdapter(TimeoutTapDevice(), 10) adapter = UiautomatorSkuPanelAdapter(TimeoutTapDevice(), 10)
with self.assertRaises(SkuSelectionRunError): with self.assertRaises(SkuSelectionRunError):
adapter.tap_sku_entry("[900,1312][1056,1355]") adapter.tap_sku_entry("[688,2184][1042,2253]")
self.assertTrue(adapter.entry_was_tapped) self.assertTrue(adapter.entry_was_tapped)
self.assertEqual(_actions(adapter._device, "click"), [("jsonrpc", "click", [978, 1333], 10)]) self.assertEqual(_actions(adapter._device, "click"), [("jsonrpc", "click", [865, 2218], 10)])
def test_entry_stability_interruptions_never_click(self) -> None: def test_entry_stability_interruptions_never_click(self) -> None:
now = [0.0] now = [0.0]