2026-08-04 17:47:58 +08:00
from __future__ import annotations
import ast
import base64
2026-08-05 17:25:27 +08:00
from contextlib import redirect_stderr , redirect_stdout
2026-08-05 16:55:10 +08:00
from functools import lru_cache
2026-08-05 17:25:27 +08:00
from io import BytesIO , StringIO
2026-08-04 17:47:58 +08:00
import importlib.util
2026-08-05 16:55:10 +08:00
import json
2026-08-04 17:47:58 +08:00
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
2026-08-05 09:07:30 +08:00
from cmbuyer_client.device.adb import AdbDevice , DeviceConnectionError , DeviceInspection
2026-08-04 17:47:58 +08:00
from cmbuyer_client.pdd import SkuSelectionError , SkuSelectionFlow , SkuSelectionRunner
2026-08-05 12:34:59 +08:00
from cmbuyer_client.pdd.sku_selection import (
2026-08-05 15:20:09 +08:00
_PanelProfile ,
2026-08-05 12:34:59 +08:00
SkuPanelDevice ,
_action_bounds ,
_classify_panel ,
_parse_nodes ,
resolve_task_selection ,
)
2026-08-04 17:47:58 +08:00
from cmbuyer_client.pdd.sku_selection_runner import (
2026-08-05 17:25:27 +08:00
SkuExitSpikeCapturer ,
SkuExitSpikeError ,
2026-08-04 17:47:58 +08:00
SkuSelectionDeviceAdapterError ,
SkuSelectionRunError ,
SkuSelectionScreenshotError ,
2026-08-05 17:25:27 +08:00
UiautomatorSkuExitAdapter ,
2026-08-04 17:47:58 +08:00
UiautomatorSkuPanelAdapter ,
2026-08-05 09:07:30 +08:00
safe_failure_stage ,
2026-08-04 17:47:58 +08:00
)
2026-08-05 12:34:59 +08:00
_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"
2026-08-05 15:20:09 +08:00
_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"
2026-08-05 12:34:59 +08:00
_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
2026-08-04 18:21:30 +08:00
_ENTRY_FIXTURE = Path ( __file__ ) . with_name ( "fixtures" ) / "product_entry_8_17_0.xml"
2026-08-04 17:47:58 +08:00
_TARGET_URL = "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"
_TASK_COLOR = "黑色CHA(纯棉)"
_TASK_SIZE = "M(建议100-115)"
2026-08-04 18:21:30 +08:00
_PRODUCT_PAGE = _ENTRY_FIXTURE . read_text ( encoding = "utf-8" )
2026-08-04 17:47:58 +08:00
2026-08-05 16:55:10 +08:00
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 )
2026-08-04 17:47:58 +08:00
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
2026-08-05 16:55:10 +08:00
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 ()
2026-08-04 17:47:58 +08:00
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 ""
2026-08-05 16:55:10 +08:00
if method == "swipe" :
if params != [ 360 , 1900 , 360 , 1300 , 30 ]:
raise AssertionError ( params )
self . hierarchy = self . revealed_hierarchy
return ""
2026-08-04 17:47:58 +08:00
raise AssertionError ( method )
def _apply_tap ( self , x : int , y : int ) -> None :
2026-08-05 10:56:55 +08:00
if (
( x , y ) == ( 865 , 2218 )
and "快要抢光 ¥ 12.88" in self . hierarchy
and "[396,498][895,570]" not in self . hierarchy
):
2026-08-04 17:47:58 +08:00
self . hierarchy = self . panel_hierarchy
return
2026-08-05 12:34:59 +08:00
if ( x , y ) == ( 528 , 1387 ):
self . hierarchy = (
_EMPTY_FIXTURE . read_text ( encoding = "utf-8" )
if self . fail_color_readback
2026-08-05 15:20:09 +08:00
else self . color_hierarchy
2026-08-05 12:34:59 +08:00
)
return
if ( x , y ) == ( 635 , 1624 ):
self . hierarchy = _M_FIXTURE . read_text ( encoding = "utf-8" )
return
raise AssertionError (( x , y ))
2026-08-04 17:47:58 +08:00
def window_size ( self ) -> tuple [ int , int ]:
self . calls . append (( "window_size" ,))
return 1080 , 2376
def select_alternates ( self ) -> None :
2026-08-05 12:34:59 +08:00
self . panel_hierarchy = _S_FIXTURE . read_text ( encoding = "utf-8" )
2026-08-04 17:47:58 +08:00
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
2026-08-05 12:47:22 +08:00
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 ),
)
2026-08-05 12:34:59 +08:00
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" )
2026-08-05 15:20:09 +08:00
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" )
2026-08-04 18:21:30 +08:00
def _entry_chain ( root : ElementTree . Element ) -> list [ ElementTree . Element ]:
parents = { child : parent for parent in root . iter () for child in parent }
2026-08-05 10:56:55 +08:00
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" ) == "免拼购买" )
2026-08-04 18:21:30 +08:00
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 )
2026-08-05 10:56:55 +08:00
root . remove ( _entry_chain ( root )[ 1 ])
2026-08-04 18:21:30 +08:00
return ElementTree . tostring ( root , encoding = "unicode" )
def _duplicate_entry () -> str :
root = ElementTree . fromstring ( _PRODUCT_PAGE )
2026-08-05 10:56:55 +08:00
entry_root = _entry_chain ( root )[ 1 ]
2026-08-04 18:21:30 +08:00
root . append ( ElementTree . fromstring ( ElementTree . tostring ( entry_root , encoding = "unicode" )))
return ElementTree . tostring ( root , encoding = "unicode" )
2026-08-05 10:10:56 +08:00
def _extra_entry_action_ancestor () -> str :
root = ElementTree . fromstring ( _PRODUCT_PAGE )
2026-08-05 10:56:55 +08:00
action = _entry_chain ( root )[ 1 ]
2026-08-05 10:10:56 +08:00
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 )
2026-08-05 10:56:55 +08:00
_entry_chain ( root )[ 1 ] . set ( "content-desc" , action_desc or "快要抢光¥12.88" )
2026-08-05 10:10:56 +08:00
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 ,
* ,
2026-08-05 10:56:55 +08:00
bounds : str = "[500,2170][600,2200]" ,
2026-08-05 10:10:56 +08:00
) -> str :
root = ElementTree . fromstring ( _PRODUCT_PAGE )
2026-08-05 10:56:55 +08:00
action = _entry_chain ( root )[ 1 ]
2026-08-05 10:10:56 +08:00
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" )
2026-08-05 10:56:55 +08:00
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" )
2026-08-05 10:10:56 +08:00
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" )
2026-08-04 17:47:58 +08:00
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 ):
2026-08-04 18:21:30 +08:00
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" ), [])
2026-08-05 16:55:10 +08:00
def test_current_only_exact_transition_reveals_then_selects_m_once ( self ) -> None :
2026-08-04 17:47:58 +08:00
device = _RawDevice ()
2026-08-05 12:47:22 +08:00
flow = _flow ( device )
2026-08-04 17:47:58 +08:00
flow . open_sku_panel ( _TARGET_URL )
2026-08-05 16:55:10 +08:00
flow . select_sku_options ( resolve_task_selection ( _TASK_COLOR , _TASK_SIZE ))
self . assertEqual ( flow . read_sku_unit_price (), "12.88" )
2026-08-04 17:47:58 +08:00
2026-08-05 16:55:10 +08:00
self . assertEqual ( _tap_centers ( device ), [( 865 , 2218 ), ( 528 , 1387 ), ( 635 , 1624 )])
self . assertEqual ( len ( _actions ( device , "swipe" )), 1 )
2026-08-05 12:34:59 +08:00
self . assertEqual ( _actions ( device , "pressKey" ), [])
restored = _RawDevice ( _S_FIXTURE . read_text ( encoding = "utf-8" ))
2026-08-05 12:47:22 +08:00
restored_flow = _flow ( restored )
2026-08-05 12:34:59 +08:00
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 )])
2026-08-05 16:55:10 +08:00
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" ), [])
2026-08-04 17:47:58 +08:00
2026-08-05 15:20:09 +08:00
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 )
2026-08-05 16:55:10 +08:00
flow . select_sku_options ( resolve_task_selection ( _TASK_COLOR , _TASK_SIZE ))
2026-08-05 15:20:09 +08:00
2026-08-05 16:55:10 +08:00
self . assertEqual ( _tap_centers ( device ), [( 865 , 2218 ), ( 528 , 1387 ), ( 635 , 1624 )])
self . assertEqual ( len ( _actions ( device , "swipe" )), 1 )
2026-08-05 15:20:09 +08:00
matched = _classify_panel ( _parse_nodes ( device . hierarchy ))
2026-08-05 16:55:10 +08:00
self . assertIs ( matched . profile , _PanelProfile . TARGETS_SELECTED )
2026-08-05 15:20:09 +08:00
self . assertEqual (
matched . price_layout . __class__ . __name__ ,
2026-08-05 16:55:10 +08:00
"_DualPriceLayout" ,
2026-08-05 15:20:09 +08:00
)
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 ))
2026-08-05 16:55:10 +08:00
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" ), [])
2026-08-05 15:20:09 +08:00
self . assertEqual ( _actions ( device , "pressKey" ), [])
2026-08-04 18:21:30 +08:00
def test_full_verified_entry_structure_taps_exact_text_child_once ( self ) -> None :
device = _RawDevice ()
2026-08-05 12:47:22 +08:00
_flow ( device ) . open_sku_panel ( _TARGET_URL )
2026-08-04 18:21:30 +08:00
2026-08-05 10:56:55 +08:00
self . assertEqual ( _tap_centers ( device ), [( 865 , 2218 )])
self . assertNotIn (( 763 , 2247 ), _tap_centers ( device )) # 父容器中心不是获准目标。
self . assertNotIn (( 772 , 2280 ), _tap_centers ( device )) # 第二行“免拼购买”永不点击。
2026-08-04 18:21:30 +08:00
2026-08-05 10:10:56 +08:00
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
2026-08-05 10:56:55 +08:00
self . hierarchy = _dynamic_product_page ( str ( self . frame ))
2026-08-05 10:10:56 +08:00
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 )
2026-08-05 10:56:55 +08:00
self . assertEqual ( _tap_centers ( device ), [( 865 , 2218 )])
2026-08-05 10:10:56 +08:00
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 ()
2026-08-05 12:47:22 +08:00
_flow ( device ) . open_sku_panel (
2026-08-05 10:10:56 +08:00
_TARGET_URL ,
_home_with_unverified_entry_labels (),
)
2026-08-05 10:56:55 +08:00
self . assertEqual ( _tap_centers ( device ), [( 865 , 2218 )])
2026-08-05 10:10:56 +08:00
2026-08-04 18:21:30 +08:00
def test_entry_child_and_every_ancestor_attribute_drift_never_clicks ( self ) -> None :
2026-08-05 10:56:55 +08:00
expected_clickable = ( "false" , "true" )
for depth in range ( 2 ):
2026-08-04 18:21:30 +08:00
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 ))
2026-08-05 10:10:56 +08:00
def test_entry_chain_text_and_description_drift_never_clicks ( self ) -> None :
cases = [
2026-08-05 10:56:55 +08:00
_mutate_entry ( 1 , "text" , "祖先文字漂移" )
2026-08-05 10:10:56 +08:00
] + [
2026-08-05 10:56:55 +08:00
_mutate_entry ( 0 , "content-desc" , "祖先描述漂移" ),
_mutate_entry ( 1 , "content-desc" , "祖先描述漂移" ),
2026-08-05 10:10:56 +08:00
] + [
2026-08-05 10:56:55 +08:00
_mutate_entry ( 1 , "content-desc" , forbidden + "快要抢光" )
2026-08-05 10:10:56 +08:00
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" ,
2026-08-05 10:56:55 +08:00
"[850,2200][900,2230]" ,
2026-08-05 10:10:56 +08:00
),
_with_overlapping_clickable (
"com.android.systemui" ,
"android.view.ViewGroup" ,
2026-08-05 10:56:55 +08:00
"[800,2100][1000,2300]" ,
2026-08-05 10:10:56 +08:00
),
_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 (),
2026-08-05 10:56:55 +08:00
_mutate_entry ( 1 , "content-desc" , "立即购买快要抢光" ),
2026-08-05 10:10:56 +08:00
):
with self . subTest ():
device = _RawDevice ()
2026-08-05 12:47:22 +08:00
flow = _flow ( device )
2026-08-05 10:10:56 +08:00
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 )
2026-08-04 18:21:30 +08:00
def test_duplicate_entry_and_forbidden_sibling_entry_never_click ( self ) -> None :
self . _assert_entry_rejected_without_click ( _duplicate_entry ())
2026-08-05 10:10:56 +08:00
self . _assert_entry_rejected_without_click ( _extra_entry_action_ancestor ())
2026-08-04 18:21:30 +08:00
self . _assert_entry_rejected_without_click ( _without_entry ())
self . _assert_entry_rejected_without_click ( _mutate_entry ( 0 , "clickable" , "true" ))
2026-08-05 10:56:55 +08:00
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 )
2026-08-04 17:47:58 +08:00
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 ):
2026-08-05 12:47:22 +08:00
_flow ( device ) . select_sku_options (
2026-08-04 17:47:58 +08:00
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 = (
2026-08-05 12:34:59 +08:00
_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" ),
2026-08-04 17:47:58 +08:00
)
for hierarchy in cases :
with self . subTest (), self . assertRaises ( SkuSelectionError ):
2026-08-05 12:47:22 +08:00
_flow ( _RawDevice ( hierarchy )) . select_sku_options ( resolve_task_selection ( _TASK_COLOR , _TASK_SIZE ))
2026-08-04 17:47:58 +08:00
2026-08-05 12:34:59 +08:00
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" )))
2026-08-04 17:47:58 +08:00
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 )
2026-08-05 10:56:55 +08:00
device = _RawDevice ( _PRODUCT_PAGE . replace ( "[688,2184][1042,2253]" , "[0,0][1081,1]" ))
2026-08-04 17:47:58 +08:00
with self . assertRaises ( SkuSelectionError ):
2026-08-05 12:47:22 +08:00
_flow ( device ) . open_sku_panel ( _TARGET_URL )
2026-08-04 17:47:58 +08:00
self . assertEqual ( _actions ( device , "click" ), [])
def test_color_readback_failure_never_attempts_second_option ( self ) -> None :
device = _RawDevice ()
device . fail_color_readback = True
2026-08-05 12:47:22 +08:00
flow = _flow ( device )
2026-08-04 17:47:58 +08:00
flow . open_sku_panel ( _TARGET_URL )
with self . assertRaises ( SkuSelectionError ):
flow . select_sku_options ( resolve_task_selection ( _TASK_COLOR , _TASK_SIZE ))
2026-08-05 12:34:59 +08:00
self . assertEqual ( _tap_centers ( device ), [( 865 , 2218 ), ( 528 , 1387 )])
2026-08-04 17:47:58 +08:00
def test_non_target_selection_restores_each_dimension_once ( self ) -> None :
device = _RawDevice ()
2026-08-05 12:47:22 +08:00
flow = _flow ( device )
2026-08-04 17:47:58 +08:00
flow . open_sku_panel ( _TARGET_URL )
device . select_alternates ()
flow . select_sku_options ( resolve_task_selection ( _TASK_COLOR , _TASK_SIZE ))
2026-08-05 16:55:10 +08:00
self . assertEqual ( _tap_centers ( device ), [( 865 , 2218 ), ( 635 , 1624 )])
self . assertEqual ( _actions ( device , "swipe" ), [])
2026-08-04 17:47:58 +08:00
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 ):
2026-08-05 12:47:22 +08:00
_flow ( device ) . read_sku_unit_price ()
2026-08-04 17:47:58 +08:00
device = _RawDevice ( _FIXTURE . read_text ( encoding = "utf-8" ) . replace ( "快卖完 ¥12.88" , "提交订单 ¥12.88" ))
with self . assertRaises ( SkuSelectionError ):
2026-08-05 12:47:22 +08:00
_flow ( device ) . read_sku_unit_price ()
2026-08-05 12:34:59 +08:00
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" ,
2026-08-04 17:47:58 +08:00
)
with self . assertRaises ( SkuSelectionError ):
2026-08-05 12:47:22 +08:00
_flow ( _RawDevice ( clickable_parent )) . read_sku_unit_price ()
2026-08-04 17:47:58 +08:00
def test_public_api_and_protocol_have_no_broad_or_order_operations ( self ) -> None :
2026-08-05 16:55:10 +08:00
forbidden = { "quantity" , "confirm" , "authorization" , "fence" , "submit" , "payment" , "click" , "swipe" , "scroll" }
2026-08-04 17:47:58 +08:00
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" ,
2026-08-05 12:34:59 +08:00
root / "src" / "cmbuyer_client" / "pdd" / "sku_reveal_spike.py" ,
root / "scripts" / "capture_sku_reveal_spike.py" ,
2026-08-04 17:47:58 +08:00
)
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 )
2026-08-05 16:55:10 +08:00
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 )
2026-08-04 17:47:58 +08:00
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 ):
2026-08-05 12:47:22 +08:00
_flow ( device ) . open_sku_panel ( _TARGET_URL )
2026-08-05 10:56:55 +08:00
self . assertEqual ( _tap_centers ( device ), [( 865 , 2218 )])
2026-08-04 17:47:58 +08:00
2026-08-04 18:21:30 +08:00
duplicate = _duplicate_entry ()
2026-08-04 17:47:58 +08:00
device = _RawDevice ( duplicate )
with self . assertRaises ( SkuSelectionError ):
2026-08-05 12:47:22 +08:00
_flow ( device ) . open_sku_panel ( _TARGET_URL )
2026-08-04 17:47:58 +08:00
self . assertEqual ( _actions ( device , "click" ), [])
def test_fixture_contains_no_address_phone_or_payment_credentials ( self ) -> None :
2026-08-05 12:34:59 +08:00
for fixture in (
_EMPTY_FIXTURE ,
_COLOR_FIXTURE ,
2026-08-05 15:20:09 +08:00
_CURRENT_ONLY_EMPTY_FIXTURE ,
_CURRENT_ONLY_COLOR_FIXTURE ,
2026-08-05 12:34:59 +08:00
_S_FIXTURE ,
_M_FIXTURE ,
_ENTRY_FIXTURE ,
):
2026-08-04 18:21:30 +08:00
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 )
2026-08-05 12:34:59 +08:00
self . assertNotIn ( "提交订单" , content )
class _CompletedFlow :
2026-08-05 16:55:10 +08:00
"""仅隔离 runner 文件发布测试,同时必须形成完整动作审计链。"""
2026-08-05 12:34:59 +08:00
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 :
2026-08-05 16:55:10 +08:00
self . device . tap_sku_entry ( "[688,2184][1042,2253]" )
2026-08-05 12:34:59 +08:00
def select_sku_options ( self , selection : object ) -> None :
2026-08-05 16:55:10 +08:00
self . device . tap_sku_option ( "[372,1188][684,1587]" )
self . device . reveal_size_options_once ()
self . device . tap_sku_option ( "[439,1582][831,1667]" )
2026-08-05 12:34:59 +08:00
def verify_target_selection_and_read_price ( self , selection : object ) -> str :
return "12.88"
def exit_sku_panel_safely ( self ) -> None :
2026-08-05 16:55:10 +08:00
self . device . leave_sku_panel ()
2026-08-05 12:34:59 +08:00
def reconcile_pending_action ( self ) -> None :
return None
2026-08-04 17:47:58 +08:00
class SkuSelectionRunnerTests ( unittest . TestCase ):
2026-08-05 12:47:22 +08:00
def setUp ( self ) -> None :
flow_patch = patch . object ( runner_module , "SkuSelectionFlow" , _fast_runner_flow )
flow_patch . start ()
self . addCleanup ( flow_patch . stop )
2026-08-04 17:47:58 +08:00
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">' ))
2026-08-05 12:34:59 +08:00
return SkuSelectionRunner ( adb , lambda serial : device , 0.03 )
2026-08-04 17:47:58 +08:00
def test_runner_atomically_publishes_screenshot_and_redacted_manifest ( self ) -> None :
adb = _FakeAdb ()
device = _RawDevice ()
with TemporaryDirectory () as temporary :
target = Path ( temporary ) / "result"
2026-08-05 12:34:59 +08:00
with patch . object ( runner_module , "SkuSelectionFlow" , _CompletedFlow ):
result = self . _runner ( adb , device ) . run ( "device-1" , _TARGET_URL , _TASK_COLOR , _TASK_SIZE , target )
2026-08-04 17:47:58 +08:00
self . assertEqual ( result . unit_price , "12.88" )
manifest = result . manifest_path . read_text ( encoding = "utf-8" )
2026-08-05 16:55:10 +08:00
manifest_data = json . loads ( manifest )
2026-08-04 17:47:58 +08:00
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 )
2026-08-05 16:55:10 +08:00
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" },
},
)
2026-08-04 17:47:58 +08:00
self . assertFalse (( target / "hierarchy.xml" ) . exists ())
2026-08-05 16:55:10 +08:00
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-*" )), [])
2026-08-05 12:34:59 +08:00
self . assertEqual ( _actions ( device , "pressKey" ), [])
2026-08-04 17:47:58 +08:00
2026-08-05 09:07:30 +08:00
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 )
2026-08-05 10:32:35 +08:00
adb = _FakeAdb ()
device = NoPanelAfterEntry ( "<hierarchy />" )
adb . on_intent = lambda : setattr ( device , "hierarchy" , _PRODUCT_PAGE )
2026-08-05 09:07:30 +08:00
with TemporaryDirectory () as temporary , self . assertRaises ( SkuSelectionError ) as raised :
2026-08-05 10:32:35 +08:00
SkuSelectionRunner ( adb , lambda serial : device , 0.03 ) . run (
2026-08-05 09:07:30 +08:00
"device-1" , _TARGET_URL , _TASK_COLOR , _TASK_SIZE , Path ( temporary ) / "result"
)
2026-08-05 10:32:35 +08:00
self . assertEqual ( safe_failure_stage ( raised . exception ), "sku_entry_panel_verify" )
2026-08-05 09:07:30 +08:00
forged = SkuSelectionError ( "<hierarchy>private</hierarchy>" )
2026-08-05 10:32:35 +08:00
setattr ( forged , "_cmbuyer_failure_stage" , "sku_entry_click" )
2026-08-05 09:07:30 +08:00
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" )
2026-08-05 10:32:35 +08:00
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 )
2026-08-04 17:47:58 +08:00
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 )
2026-08-05 12:34:59 +08:00
with (
patch . object ( runner_module , "SkuSelectionFlow" , _CompletedFlow ),
patch . object ( runner_module . os , "rename" , side_effect = create_target_then_rename ),
self . assertRaises ( SkuSelectionRunError ),
):
2026-08-04 17:47:58 +08:00
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"
2026-08-05 12:34:59 +08:00
with (
patch . object ( runner_module , "SkuSelectionFlow" , _CompletedFlow ),
self . assertRaises ( SkuSelectionScreenshotError ) as screenshot_failure ,
):
2026-08-04 17:47:58 +08:00
self . _runner ( _FakeAdb (), _RawDevice ( screenshot = "not-image" )) . run ( "device-1" , _TARGET_URL , _TASK_COLOR , _TASK_SIZE , target )
2026-08-05 09:07:30 +08:00
self . assertEqual ( safe_failure_stage ( screenshot_failure . exception ), "screenshot_capture" )
2026-08-04 17:47:58 +08:00
self . assertFalse ( target . exists ())
self . assertEqual ( list ( Path ( temporary ) . glob ( ".result.staging-*" )), [])
target = Path ( temporary ) / "write-failure"
2026-08-05 12:34:59 +08:00
with (
patch . object ( runner_module , "SkuSelectionFlow" , _CompletedFlow ),
patch . object ( runner_module , "_save_base64_screenshot" , side_effect = OSError ( "private path" )),
):
2026-08-04 17:47:58 +08:00
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" )
2026-08-05 09:07:30 +08:00
with self . assertRaises ( SkuSelectionRunError ) as existing_target_failure :
2026-08-04 17:47:58 +08:00
self . _runner ( adb , device ) . run ( "device-1" , _TARGET_URL , _TASK_COLOR , _TASK_SIZE , target )
2026-08-05 09:07:30 +08:00
self . assertEqual ( safe_failure_stage ( existing_target_failure . exception ), "precheck" )
2026-08-04 17:47:58 +08:00
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" )
2026-08-05 12:34:59 +08:00
with (
TemporaryDirectory () as temporary ,
patch . object ( runner_module , "SkuSelectionFlow" , _CompletedFlow ),
self . assertRaises ( SkuSelectionScreenshotError ),
):
2026-08-04 17:47:58 +08:00
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 ):
2026-08-05 10:56:55 +08:00
adapter . tap_sku_entry ( "[688,2184][1042,2253]" )
2026-08-04 17:47:58 +08:00
self . assertTrue ( adapter . entry_was_tapped )
2026-08-05 10:56:55 +08:00
self . assertEqual ( _actions ( adapter . _device , "click" ), [( "jsonrpc" , "click" , [ 865 , 2218 ], 10 )])
2026-08-04 17:47:58 +08:00
2026-08-05 16:55:10 +08:00
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" )
2026-08-04 17:47:58 +08:00
def test_entry_stability_interruptions_never_click ( self ) -> None :
now = [ 0.0 ]
class SequenceDevice ( _RawDevice ):
def __init__ ( self ) -> None :
2026-08-05 10:10:56 +08:00
super () . __init__ (); self . frames = [
_dynamic_product_page ( "first" ),
"<hierarchy />" ,
_dynamic_product_page ( "second" ),
]
2026-08-04 17:47:58 +08:00
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"
2026-08-05 12:47:22 +08:00
with self . assertRaises ( SkuSelectionError ): _flow ( device ) . exit_sku_panel_safely ()
2026-08-04 17:47:58 +08:00
self . assertEqual ( _actions ( device , "pressKey" ), [])
2026-08-05 16:55:10 +08:00
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 :
2026-08-05 12:34:59 +08:00
device = _RawDevice ()
2026-08-05 16:55:10 +08:00
device . panel_hierarchy = _EMPTY_FIXTURE . read_text ( encoding = "utf-8" )
device . color_hierarchy = _COLOR_FIXTURE . read_text ( encoding = "utf-8" )
2026-08-04 17:47:58 +08:00
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-*" )), [])
2026-08-05 16:55:10 +08:00
self . assertEqual ( _actions ( device , "swipe" ), [])
self . assertEqual ( len ( _actions ( device , "pressKey" )), 0 )
2026-08-04 17:47:58 +08:00
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 )
2026-08-05 16:55:10 +08:00
for delivered , expected_back in (( False , 0 ), ( True , 0 )):
2026-08-04 17:47:58 +08:00
with self . subTest ( delivered = delivered ), TemporaryDirectory () as temporary :
2026-08-05 12:34:59 +08:00
device = OptionTimeoutDevice ( delivered )
2026-08-04 17:47:58 +08:00
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 )
2026-08-05 12:34:59 +08:00
device = BackTimeoutDevice ( _M_FIXTURE . read_text ( encoding = "utf-8" ))
with self . assertRaises ( SkuSelectionRunError ):
SkuSelectionFlow ( UiautomatorSkuPanelAdapter ( device , 0.03 )) . exit_sku_panel_safely ()
2026-08-04 17:47:58 +08:00
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 )
2026-08-05 16:55:10 +08:00
self . assertEqual ( len ( _actions ( device , "pressKey" )), 0 )
2026-08-04 17:47:58 +08:00
2026-08-05 17:25:27 +08:00
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" ), [])
2026-08-05 17:39:12 +08:00
def test_evidence_bound_rolled_target_color_variant_is_exactly_accepted ( self ) -> None :
hierarchy = _M_FIXTURE . read_text ( encoding = "utf-8" )
old_bounds = "[372,1000][684,1024]"
new_bounds = "[126,1000][438,1024]"
self . assertEqual ( hierarchy . count ( old_bounds ), 4 )
device = _ExitEvidenceDevice ( hierarchy . replace ( old_bounds , new_bounds ))
with TemporaryDirectory () as temporary :
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 [ "post_exit_status" ], "human_review_required" )
self . assertEqual ( len ( _actions ( device , "pressKey" )), 1 )
unknown_device = _ExitEvidenceDevice ( hierarchy . replace ( old_bounds , "[127,1000][439,1024]" ))
with TemporaryDirectory () as temporary :
target = Path ( temporary ) / "unknown"
with self . assertRaises ( SkuSelectionError ):
self . _capturer ( _FakeAdb (), unknown_device ) . capture ( "device-1" , target )
self . assertEqual ( _actions ( unknown_device , "pressKey" ), [])
self . assertFalse ( target . exists ())
2026-08-05 17:25:27 +08:00
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" ,
},
)
2026-08-04 17:47:58 +08:00
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 ()
2026-08-05 09:07:30 +08:00
secret = "SERIAL=192.168.0.173:5555 PATH=C:/private <hierarchy>page-body</hierarchy>"
2026-08-04 17:47:58 +08:00
2026-08-05 09:07:30 +08:00
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 )
2026-08-04 17:47:58 +08:00
2026-08-05 17:25:27 +08:00
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 )
2026-08-04 17:47:58 +08:00
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
2026-08-05 17:25:27 +08:00
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
2026-08-04 17:47:58 +08:00
if __name__ == "__main__" :
unittest . main ()