feat(client): capture one-shot sku reveal evidence
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1077]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,575][912,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="限1件 ¥12.88 " package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,580][675,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<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" />
|
||||
</node>
|
||||
<node text="请选择: 尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,731][1053,793]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1077][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,1188]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="颜色分类" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1106][192,1159]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1188][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1188][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1188][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][684,1500]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node content-desc="打开大图" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][483,1299]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1479][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1479][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,2052][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,2052][1044,2079]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,2069][114,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1077]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,575][912,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="限1件 ¥12.88 " package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,580][675,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<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" />
|
||||
</node>
|
||||
<node text="请选择: 颜色分类 尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,731][1053,793]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1077][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,1188]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="颜色分类" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1106][192,1159]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1188][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1188][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1188][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][684,1500]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node content-desc="打开大图" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][483,1299]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1479][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1479][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,2052][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,2052][1044,2079]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,2069][114,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1000]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,498][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="快卖完 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,503][712,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node text="¥29.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[730,503][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node text="已选: 黑色 CHA (纯棉) M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,654][1053,716]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1000][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1000][1080,1489]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1000][1080,1489]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1000][1080,1483]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1000][1080,1483]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1000][684,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1000][684,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1000][684,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1000][684,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1489][1080,1930]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1489][1044,1570]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1506][114,1552]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1582][1044,1897]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1582][1044,1897]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1582][409,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="S(建议80-100)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1582][409,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[439,1582][831,1667]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1000]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,498][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="快卖完 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,503][712,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node text="¥29.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[730,503][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node text="已选: 黑色 CHA (纯棉) S(建议80-100)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,654][1053,716]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1000][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1000][1080,1489]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1000][1080,1489]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1000][1080,1483]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1000][1080,1483]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1000][684,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1000][684,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1000][684,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1000][684,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1489][1080,1930]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1489][1044,1570]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1506][114,1552]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1582][1044,1897]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1582][1044,1897]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1582][409,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="S(建议80-100)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1582][409,1667]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[36,1582][409,1667]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -0,0 +1,396 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from contextlib import redirect_stderr
|
||||
from io import BytesIO
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
|
||||
from cmbuyer_client.pdd.sku_reveal_spike import (
|
||||
SkuRevealSpikeCapturer,
|
||||
SkuRevealSpikeError,
|
||||
_require_safe_reveal_path,
|
||||
)
|
||||
from cmbuyer_client.pdd.sku_selection import SkuSelectionError, _parse_nodes
|
||||
|
||||
|
||||
_FIXTURES = Path(__file__).with_name("fixtures")
|
||||
_PRODUCT = (_FIXTURES / "product_entry_8_17_0.xml").read_text(encoding="utf-8")
|
||||
_EMPTY = (_FIXTURES / "sku_panel_empty_8_17_0.xml").read_text(encoding="utf-8")
|
||||
_COLOR_ONLY = (_FIXTURES / "sku_panel_color_selected_size_hidden_8_17_0.xml").read_text(encoding="utf-8")
|
||||
_M_SELECTED = (_FIXTURES / "sku_panel_size_m_restored_8_17_0.xml").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _candidate_unselected() -> str:
|
||||
root = ElementTree.fromstring(_M_SELECTED)
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
for node in root.iter("node"):
|
||||
if node.get("text") == "已选: 黑色 CHA (纯棉) M(建议100-115)":
|
||||
node.set("text", "请选择: 尺码")
|
||||
if node.get("text") in {"S(建议80-100)", "M(建议100-115)"}:
|
||||
node.set("selected", "false")
|
||||
wrapper = parents[node]
|
||||
for child in list(wrapper):
|
||||
if child.get("class") == "android.view.View":
|
||||
wrapper.remove(child)
|
||||
action_root = next(
|
||||
node
|
||||
for node in root.iter("node")
|
||||
if node.get("class") == "android.view.ViewGroup"
|
||||
and node.get("clickable") == "true"
|
||||
and node.get("bounds") == "[0,366][1080,2328]"
|
||||
)
|
||||
button_frame = ElementTree.SubElement(
|
||||
action_root,
|
||||
"node",
|
||||
{
|
||||
"class": "android.widget.FrameLayout",
|
||||
"package": "com.xunmeng.pinduoduo",
|
||||
"text": "",
|
||||
"content-desc": "",
|
||||
"clickable": "true",
|
||||
"enabled": "true",
|
||||
"visible-to-user": "true",
|
||||
"selected": "false",
|
||||
"scrollable": "false",
|
||||
"bounds": "[0,2181][1080,2328]",
|
||||
},
|
||||
)
|
||||
button_content = ElementTree.SubElement(
|
||||
button_frame,
|
||||
"node",
|
||||
{
|
||||
"class": "android.widget.LinearLayout",
|
||||
"package": "com.xunmeng.pinduoduo",
|
||||
"text": "",
|
||||
"content-desc": "",
|
||||
"clickable": "false",
|
||||
"enabled": "true",
|
||||
"visible-to-user": "true",
|
||||
"selected": "false",
|
||||
"scrollable": "false",
|
||||
"bounds": "[273,2181][807,2328]",
|
||||
},
|
||||
)
|
||||
ElementTree.SubElement(
|
||||
button_content,
|
||||
"node",
|
||||
{
|
||||
"class": "android.widget.TextView",
|
||||
"package": "com.xunmeng.pinduoduo",
|
||||
"text": "选择尺码后,提交订单",
|
||||
"content-desc": "",
|
||||
"clickable": "false",
|
||||
"enabled": "true",
|
||||
"visible-to-user": "true",
|
||||
"selected": "false",
|
||||
"scrollable": "false",
|
||||
"bounds": "[285,2225][795,2284]",
|
||||
},
|
||||
)
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _with_clickable_overlay(package: str, class_name: str, bounds: str) -> str:
|
||||
root = ElementTree.fromstring(_COLOR_ONLY)
|
||||
container = next(root.iter("node"))
|
||||
ElementTree.SubElement(
|
||||
container,
|
||||
"node",
|
||||
{
|
||||
"package": package,
|
||||
"class": class_name,
|
||||
"text": "",
|
||||
"content-desc": "",
|
||||
"clickable": "true",
|
||||
"enabled": "true",
|
||||
"visible-to-user": "true",
|
||||
"selected": "false",
|
||||
"scrollable": "false",
|
||||
"bounds": bounds,
|
||||
},
|
||||
)
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _png() -> str:
|
||||
image = Image.new("RGB", (1080, 2376), "white")
|
||||
raw = BytesIO()
|
||||
image.save(raw, format="PNG")
|
||||
return base64.b64encode(raw.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
class _FakeDevice:
|
||||
def __init__(self) -> None:
|
||||
self.hierarchy = "<hierarchy />"
|
||||
self.after_hierarchy = _candidate_unselected()
|
||||
self.calls: list[tuple[object, ...]] = []
|
||||
self.swipe_error = False
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, str]:
|
||||
self.calls.append(("app_info", package_name))
|
||||
return {"versionName": "8.17.0"}
|
||||
|
||||
def app_current(self) -> dict[str, str]:
|
||||
self.calls.append(("app_current",))
|
||||
return {"package": "com.xunmeng.pinduoduo"}
|
||||
|
||||
def window_size(self) -> tuple[int, int]:
|
||||
self.calls.append(("window_size",))
|
||||
return 1080, 2376
|
||||
|
||||
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 _png()
|
||||
if method == "click":
|
||||
if params == [865, 2218]:
|
||||
self.hierarchy = _EMPTY
|
||||
return ""
|
||||
if params == [528, 1387]:
|
||||
self.hierarchy = _COLOR_ONLY
|
||||
return ""
|
||||
raise AssertionError(params)
|
||||
if method == "swipe":
|
||||
self.hierarchy = self.after_hierarchy
|
||||
if self.swipe_error:
|
||||
raise TimeoutError("unknown outcome")
|
||||
return ""
|
||||
raise AssertionError(method)
|
||||
|
||||
|
||||
class _FakeAdb:
|
||||
def __init__(self, device: _FakeDevice) -> None:
|
||||
self.device = device
|
||||
self.calls: list[tuple[object, ...]] = []
|
||||
|
||||
def inspect(self, serial: str) -> DeviceInspection:
|
||||
self.calls.append(("inspect", serial))
|
||||
return DeviceInspection(AdbDevice(serial=serial, state="device"), "PKG110", "16")
|
||||
|
||||
def start_pdd_view_intent(self, serial: str, goods_id: str) -> object:
|
||||
self.calls.append(("intent", serial, goods_id))
|
||||
self.device.hierarchy = _PRODUCT
|
||||
return object()
|
||||
|
||||
|
||||
def _swipes(device: _FakeDevice) -> list[tuple[object, ...]]:
|
||||
return [call for call in device.calls if call[:2] == ("jsonrpc", "swipe")]
|
||||
|
||||
|
||||
class SkuRevealSpikeTests(unittest.TestCase):
|
||||
def _capturer(self, device: _FakeDevice) -> SkuRevealSpikeCapturer:
|
||||
now = [0.0]
|
||||
return SkuRevealSpikeCapturer(
|
||||
_FakeAdb(device),
|
||||
lambda serial: device,
|
||||
30,
|
||||
monotonic_clock=lambda: now[0],
|
||||
sleep_function=lambda seconds: now.__setitem__(0, now[0] + seconds),
|
||||
)
|
||||
|
||||
def test_success_publishes_before_after_and_exactly_one_reveal(self) -> None:
|
||||
device = _FakeDevice()
|
||||
with TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "evidence"
|
||||
result = self._capturer(device).capture("192.168.0.173:5555", "937122477375", target)
|
||||
|
||||
self.assertEqual(result.output_directory, target)
|
||||
self.assertEqual(len(_swipes(device)), 1)
|
||||
self.assertFalse(any(call[1] == "pressKey" for call in device.calls if call[0] == "jsonrpc"))
|
||||
for relative in (
|
||||
"before/screenshot.png",
|
||||
"before/hierarchy.xml",
|
||||
"after/screenshot.png",
|
||||
"after/hierarchy.xml",
|
||||
"manifest.json",
|
||||
):
|
||||
self.assertTrue((target / relative).is_file(), relative)
|
||||
manifest_text = result.manifest_path.read_text(encoding="utf-8")
|
||||
manifest = json.loads(manifest_text)
|
||||
self.assertEqual(manifest["reveal_attempts"], 1)
|
||||
self.assertEqual(manifest["rpc_outcome"], "completed")
|
||||
self.assertNotIn("192.168.0.173:5555", manifest_text)
|
||||
self.assertNotIn("gesture", manifest)
|
||||
self.assertNotIn("coordinates", manifest)
|
||||
self.assertNotIn("start", manifest)
|
||||
self.assertNotIn("end", manifest)
|
||||
|
||||
def test_ambiguous_rpc_is_read_only_reconciled_without_retry(self) -> None:
|
||||
device = _FakeDevice()
|
||||
device.swipe_error = True
|
||||
with TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "evidence"
|
||||
result = self._capturer(device).capture("192.168.0.173:5555", "937122477375", target)
|
||||
self.assertEqual(len(_swipes(device)), 1)
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(manifest["rpc_outcome"], "ambiguous_reconciled")
|
||||
|
||||
def test_selected_m_or_precondition_drift_never_publishes_or_retries(self) -> None:
|
||||
for after in (
|
||||
_M_SELECTED,
|
||||
_candidate_unselected().replace("请选择: 尺码", "已选: 尺码"),
|
||||
):
|
||||
with self.subTest():
|
||||
device = _FakeDevice()
|
||||
device.after_hierarchy = after
|
||||
with TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "evidence"
|
||||
with self.assertRaises(SkuRevealSpikeError):
|
||||
self._capturer(device).capture("192.168.0.173:5555", "937122477375", target)
|
||||
self.assertEqual(len(_swipes(device)), 1)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(directory).glob(".*.staging-*")), [])
|
||||
|
||||
device = _FakeDevice()
|
||||
device.hierarchy = "<hierarchy />"
|
||||
invalid_color = _COLOR_ONLY.replace("请选择: 尺码", "请选择: 颜色分类 尺码")
|
||||
original_call = device.jsonrpc_call
|
||||
|
||||
def drift(method: str, params: object = None, timeout: float = 10) -> str:
|
||||
value = original_call(method, params, timeout)
|
||||
if method == "click" and params == [528, 1387]:
|
||||
device.hierarchy = invalid_color
|
||||
return value
|
||||
|
||||
device.jsonrpc_call = drift # type: ignore[method-assign]
|
||||
with TemporaryDirectory() as directory:
|
||||
with self.assertRaises((SkuSelectionError, SkuRevealSpikeError)):
|
||||
self._capturer(device).capture(
|
||||
"192.168.0.173:5555",
|
||||
"937122477375",
|
||||
Path(directory) / "evidence",
|
||||
)
|
||||
self.assertEqual(_swipes(device), [])
|
||||
|
||||
def test_invalid_goods_and_existing_target_are_zero_action(self) -> None:
|
||||
device = _FakeDevice()
|
||||
with TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "existing"
|
||||
target.mkdir()
|
||||
with self.assertRaises(SkuRevealSpikeError):
|
||||
self._capturer(device).capture("wifi", "1", Path(directory) / "new")
|
||||
with self.assertRaises(SkuRevealSpikeError):
|
||||
self._capturer(device).capture("wifi", "937122477375", target)
|
||||
self.assertEqual(device.calls, [])
|
||||
|
||||
def test_before_screenshot_drift_is_rechecked_before_zero_swipe(self) -> None:
|
||||
device = _FakeDevice()
|
||||
original_call = device.jsonrpc_call
|
||||
|
||||
def drift(method: str, params: object = None, timeout: float = 10) -> str:
|
||||
value = original_call(method, params, timeout)
|
||||
if method == "takeScreenshot":
|
||||
device.hierarchy = _COLOR_ONLY.replace("请选择: 尺码", "请选择: 颜色分类 尺码")
|
||||
return value
|
||||
|
||||
device.jsonrpc_call = drift # type: ignore[method-assign]
|
||||
with TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "evidence"
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
self._capturer(device).capture(
|
||||
"192.168.0.173:5555",
|
||||
"937122477375",
|
||||
target,
|
||||
)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(_swipes(device), [])
|
||||
|
||||
def test_complete_reveal_segment_rejects_narrow_impostor_and_invalid_bounds(self) -> None:
|
||||
for hierarchy in (
|
||||
_with_clickable_overlay(
|
||||
"com.xunmeng.pinduoduo",
|
||||
"android.view.ViewGroup",
|
||||
"[350,1450][370,1460]",
|
||||
),
|
||||
_with_clickable_overlay(
|
||||
"com.android.systemui",
|
||||
"android.view.ViewGroup",
|
||||
"[0,366][1080,2328]",
|
||||
),
|
||||
_with_clickable_overlay(
|
||||
"com.xunmeng.pinduoduo",
|
||||
"android.view.ViewGroup",
|
||||
"not-a-bound",
|
||||
),
|
||||
):
|
||||
with self.subTest(), self.assertRaises(SkuRevealSpikeError):
|
||||
_require_safe_reveal_path(_parse_nodes(hierarchy))
|
||||
|
||||
|
||||
class SkuRevealSpikeCliTests(unittest.TestCase):
|
||||
def test_cli_has_no_gesture_or_task_specification_parameters(self) -> None:
|
||||
script = _load_reveal_script()
|
||||
arguments = script.parse_arguments(
|
||||
[
|
||||
"--serial", "device-1",
|
||||
"--goods-id", "937122477375",
|
||||
"--output-dir", "evidence",
|
||||
]
|
||||
)
|
||||
self.assertEqual(
|
||||
set(vars(arguments)),
|
||||
{"serial", "goods_id", "output_dir", "timeout", "adb"},
|
||||
)
|
||||
script.validate_arguments(arguments)
|
||||
for field, value in (
|
||||
("serial", ""),
|
||||
("goods_id", "1"),
|
||||
("timeout", 0),
|
||||
("timeout", float("inf")),
|
||||
):
|
||||
with self.subTest(field=field), self.assertRaises(ValueError):
|
||||
script.validate_arguments(
|
||||
type("Arguments", (), vars(arguments) | {field: value})()
|
||||
)
|
||||
|
||||
def test_cli_failure_is_redacted(self) -> None:
|
||||
script = _load_reveal_script()
|
||||
secret = "SERIAL=192.168.0.173:5555 <hierarchy>private</hierarchy>"
|
||||
|
||||
class FailingCapturer:
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
def capture(self, *args: object, **kwargs: object) -> object:
|
||||
raise SkuRevealSpikeError(secret)
|
||||
|
||||
stderr = io.StringIO()
|
||||
with patch.object(script, "SkuRevealSpikeCapturer", FailingCapturer), redirect_stderr(stderr):
|
||||
status = script.main(
|
||||
[
|
||||
"--serial", "192.168.0.173:5555",
|
||||
"--goods-id", "937122477375",
|
||||
"--output-dir", "evidence",
|
||||
]
|
||||
)
|
||||
output = stderr.getvalue()
|
||||
self.assertEqual(status, 1)
|
||||
self.assertNotIn("Traceback", output)
|
||||
self.assertNotIn("192.168.0.173:5555", output)
|
||||
self.assertNotIn("private", output)
|
||||
|
||||
|
||||
def _load_reveal_script() -> object:
|
||||
path = Path(__file__).resolve().parents[2] / "scripts" / "capture_sku_reveal_spike.py"
|
||||
specification = importlib.util.spec_from_file_location("capture_sku_reveal_spike_test", path)
|
||||
if specification is None or specification.loader is None:
|
||||
raise RuntimeError("无法加载 T-103 reveal 取证脚本。")
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
specification.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -17,7 +17,13 @@ import cmbuyer_client.pdd as pdd
|
||||
import cmbuyer_client.pdd.sku_selection_runner as runner_module
|
||||
from cmbuyer_client.device.adb import AdbDevice, DeviceConnectionError, DeviceInspection
|
||||
from cmbuyer_client.pdd import SkuSelectionError, SkuSelectionFlow, SkuSelectionRunner
|
||||
from cmbuyer_client.pdd.sku_selection import SkuPanelDevice, _action_bounds, resolve_task_selection
|
||||
from cmbuyer_client.pdd.sku_selection import (
|
||||
SkuPanelDevice,
|
||||
_action_bounds,
|
||||
_classify_panel,
|
||||
_parse_nodes,
|
||||
resolve_task_selection,
|
||||
)
|
||||
from cmbuyer_client.pdd.sku_selection_runner import (
|
||||
SkuSelectionDeviceAdapterError,
|
||||
SkuSelectionRunError,
|
||||
@@ -27,7 +33,12 @@ from cmbuyer_client.pdd.sku_selection_runner import (
|
||||
)
|
||||
|
||||
|
||||
_FIXTURE = Path(__file__).with_name("fixtures") / "sku_panel_8_17_0.xml"
|
||||
_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"
|
||||
_S_FIXTURE = _FIXTURES / "sku_panel_size_s_selected_8_17_0.xml"
|
||||
_M_FIXTURE = _FIXTURES / "sku_panel_size_m_restored_8_17_0.xml"
|
||||
_FIXTURE = _M_FIXTURE
|
||||
_ENTRY_FIXTURE = Path(__file__).with_name("fixtures") / "product_entry_8_17_0.xml"
|
||||
_TARGET_URL = "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"
|
||||
_TASK_COLOR = "黑色CHA(纯棉)"
|
||||
@@ -45,7 +56,7 @@ def _png_base64() -> str:
|
||||
class _RawDevice:
|
||||
def __init__(self, hierarchy: str = _PRODUCT_PAGE, screenshot: str | None = None) -> None:
|
||||
self.hierarchy = hierarchy
|
||||
self.panel_hierarchy = _FIXTURE.read_text(encoding="utf-8")
|
||||
self.panel_hierarchy = _EMPTY_FIXTURE.read_text(encoding="utf-8")
|
||||
self.version = "8.17.0"
|
||||
self.package = "com.xunmeng.pinduoduo"
|
||||
self.screenshot = _png_base64() if screenshot is None else screenshot
|
||||
@@ -84,30 +95,24 @@ class _RawDevice:
|
||||
):
|
||||
self.hierarchy = self.panel_hierarchy
|
||||
return
|
||||
root = ElementTree.fromstring(self.hierarchy)
|
||||
target = next(node for node in root.iter("node") if _center(node.get("bounds", "")) == (x, y))
|
||||
color = target.get("bounds", "").endswith("][438,1172]")
|
||||
for node in root.iter("node"):
|
||||
if node.get("selected") is not None and ((color and ",1000]" in node.get("bounds", "")) or (not color and ",1730]" in node.get("bounds", ""))):
|
||||
node.set("selected", "false")
|
||||
if color and self.fail_color_readback:
|
||||
next(node for node in root.iter("node") if node.get("content-desc") == "粉红").set("selected", "true")
|
||||
else:
|
||||
target.set("selected", "true")
|
||||
self.hierarchy = ElementTree.tostring(root, encoding="unicode")
|
||||
if (x, y) == (528, 1387):
|
||||
self.hierarchy = (
|
||||
_EMPTY_FIXTURE.read_text(encoding="utf-8")
|
||||
if self.fail_color_readback
|
||||
else _COLOR_FIXTURE.read_text(encoding="utf-8")
|
||||
)
|
||||
return
|
||||
if (x, y) == (635, 1624):
|
||||
self.hierarchy = _M_FIXTURE.read_text(encoding="utf-8")
|
||||
return
|
||||
raise AssertionError((x, y))
|
||||
|
||||
def window_size(self) -> tuple[int, int]:
|
||||
self.calls.append(("window_size",))
|
||||
return 1080, 2376
|
||||
|
||||
def select_alternates(self) -> None:
|
||||
root = ElementTree.fromstring(self.panel_hierarchy)
|
||||
for node in root.iter("node"):
|
||||
if node.get("selected") is not None:
|
||||
node.set("selected", "false")
|
||||
next(node for node in root.iter("node") if node.get("content-desc") == "粉红").set("selected", "true")
|
||||
next(node for node in root.iter("node") if node.get("text") == "L(建议115-130)").set("selected", "true")
|
||||
self.panel_hierarchy = ElementTree.tostring(root, encoding="unicode")
|
||||
self.panel_hierarchy = _S_FIXTURE.read_text(encoding="utf-8")
|
||||
if self.hierarchy != _PRODUCT_PAGE:
|
||||
self.hierarchy = self.panel_hierarchy
|
||||
|
||||
@@ -119,6 +124,34 @@ def _center(bounds: str) -> tuple[int, int]:
|
||||
return left + (right - left) // 2, top + (bottom - top) // 2
|
||||
|
||||
|
||||
def _mutate_unique_node(
|
||||
hierarchy: str,
|
||||
*,
|
||||
attribute: str,
|
||||
value: str | None,
|
||||
text: str | None = None,
|
||||
desc: str | None = None,
|
||||
class_name: str | None = None,
|
||||
bounds: str | None = None,
|
||||
) -> str:
|
||||
root = ElementTree.fromstring(hierarchy)
|
||||
matches = [
|
||||
node
|
||||
for node in root.iter("node")
|
||||
if (text is None or node.get("text") == text)
|
||||
and (desc is None or node.get("content-desc") == desc)
|
||||
and (class_name is None or node.get("class") == class_name)
|
||||
and (bounds is None or node.get("bounds") == bounds)
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise AssertionError(f"fixture node match count: {len(matches)}")
|
||||
if value is None:
|
||||
matches[0].attrib.pop(attribute, None)
|
||||
else:
|
||||
matches[0].set(attribute, value)
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _entry_chain(root: ElementTree.Element) -> list[ElementTree.Element]:
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
child = next(node for node in root.iter("node") if node.get("text") == "快要抢光 ¥ 12.88")
|
||||
@@ -286,18 +319,26 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
flow.open_sku_panel(_TARGET_URL)
|
||||
self.assertEqual(_actions(device, "click"), [])
|
||||
|
||||
def test_target_mapping_is_exact_and_success_path_restores_target(self) -> None:
|
||||
def test_target_mapping_stops_at_unproven_reveal_then_s_to_m_is_exact(self) -> None:
|
||||
device = _RawDevice()
|
||||
adapter = UiautomatorSkuPanelAdapter(device, 10)
|
||||
flow = SkuSelectionFlow(adapter)
|
||||
|
||||
flow.open_sku_panel(_TARGET_URL)
|
||||
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||
self.assertEqual(flow.read_sku_unit_price(), "12.88")
|
||||
flow.exit_sku_panel_safely()
|
||||
with self.assertRaisesRegex(SkuSelectionError, "受控显示动作尚未取证"):
|
||||
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||
|
||||
self.assertEqual(_tap_centers(device), [(865, 2218)])
|
||||
self.assertEqual(_actions(device, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
|
||||
self.assertEqual(_tap_centers(device), [(865, 2218), (528, 1387)])
|
||||
self.assertEqual(_actions(device, "pressKey"), [])
|
||||
|
||||
restored = _RawDevice(_S_FIXTURE.read_text(encoding="utf-8"))
|
||||
restored_flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(restored, 10))
|
||||
restored_flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||
self.assertEqual(restored_flow.read_sku_unit_price(), "12.88")
|
||||
restored_flow.exit_sku_panel_safely()
|
||||
|
||||
self.assertEqual(_tap_centers(restored), [(635, 1624)])
|
||||
self.assertEqual(_actions(restored, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
|
||||
|
||||
def test_full_verified_entry_structure_taps_exact_text_child_once(self) -> None:
|
||||
device = _RawDevice()
|
||||
@@ -498,15 +539,45 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
def test_option_selected_and_container_drift_fail_closed_before_click(self) -> None:
|
||||
base = _FIXTURE.read_text(encoding="utf-8")
|
||||
cases = (
|
||||
base.replace('selected="true" clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"', 'clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"'),
|
||||
base.replace('selected="true" clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"', 'selected="maybe" clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"'),
|
||||
base.replace('bounds="[126,1000][438,1172]"', 'bounds="[1,1][20,20]"'),
|
||||
base.replace('enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"', 'enabled="false" visible-to-user="true" bounds="[126,1000][438,1172]"'),
|
||||
_mutate_unique_node(base, desc="黑色 CHA (纯棉)", class_name="android.view.ViewGroup", attribute="selected", value=None),
|
||||
_mutate_unique_node(base, desc="黑色 CHA (纯棉)", class_name="android.view.ViewGroup", attribute="selected", value="maybe"),
|
||||
_mutate_unique_node(base, desc="黑色 CHA (纯棉)", class_name="android.view.ViewGroup", attribute="bounds", value="[1,1][20,20]"),
|
||||
_mutate_unique_node(base, desc="黑色 CHA (纯棉)", class_name="android.view.ViewGroup", attribute="enabled", value="false"),
|
||||
)
|
||||
for hierarchy in cases:
|
||||
with self.subTest(), self.assertRaises(SkuSelectionError):
|
||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(_RawDevice(hierarchy), 10)).select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||
|
||||
def test_unknown_clickable_action_ancestor_is_rejected_by_profile(self) -> None:
|
||||
root = ElementTree.fromstring(_EMPTY_FIXTURE.read_text(encoding="utf-8"))
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
target = next(
|
||||
node
|
||||
for node in root.iter("node")
|
||||
if node.get("content-desc") == "黑色 CHA (纯棉)"
|
||||
and node.get("class") == "android.view.ViewGroup"
|
||||
)
|
||||
region = parents[target]
|
||||
region.remove(target)
|
||||
unknown = ElementTree.SubElement(
|
||||
region,
|
||||
"node",
|
||||
{
|
||||
"package": "com.xunmeng.pinduoduo",
|
||||
"class": "android.view.ViewGroup",
|
||||
"bounds": "[0,1188][1080,2046]",
|
||||
"clickable": "true",
|
||||
"enabled": "true",
|
||||
"visible-to-user": "true",
|
||||
"selected": "false",
|
||||
"scrollable": "false",
|
||||
},
|
||||
)
|
||||
unknown.append(target)
|
||||
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
_classify_panel(_parse_nodes(ElementTree.tostring(root, encoding="unicode")))
|
||||
|
||||
def test_invalid_bounds_stop_before_action(self) -> None:
|
||||
for bounds in ("", "[1,2][1,3]", "[1,2][3,2]", "[0,0][1081,1]", "[0,0][1,2377]", "[a,0][1,1]"):
|
||||
with self.subTest(bounds=bounds), self.assertRaises(SkuSelectionError):
|
||||
@@ -522,10 +593,9 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
device.fail_color_readback = True
|
||||
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10))
|
||||
flow.open_sku_panel(_TARGET_URL)
|
||||
device.select_alternates()
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||
self.assertEqual(_tap_centers(device), [(865, 2218), (282, 1086)])
|
||||
self.assertEqual(_tap_centers(device), [(865, 2218), (528, 1387)])
|
||||
|
||||
def test_non_target_selection_restores_each_dimension_once(self) -> None:
|
||||
device = _RawDevice()
|
||||
@@ -535,7 +605,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||
self.assertEqual(
|
||||
_tap_centers(device),
|
||||
[(865, 2218), (282, 1086), (635, 1772)],
|
||||
[(865, 2218), (635, 1624)],
|
||||
)
|
||||
|
||||
def test_price_rejects_coupon_prefix_extra_amount_and_bottom_action(self) -> None:
|
||||
@@ -547,9 +617,12 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8").replace("快卖完 ¥12.88", "提交订单 ¥12.88"))
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).read_sku_unit_price()
|
||||
clickable_parent = _FIXTURE.read_text(encoding="utf-8").replace(
|
||||
'<node package="" class="android.view.ViewGroup" bounds="[396,498][895,570]">',
|
||||
'<node package="" class="android.view.ViewGroup" clickable="true" bounds="[396,498][895,570]">',
|
||||
clickable_parent = _mutate_unique_node(
|
||||
_FIXTURE.read_text(encoding="utf-8"),
|
||||
class_name="android.widget.LinearLayout",
|
||||
bounds="[396,498][895,570]",
|
||||
attribute="clickable",
|
||||
value="true",
|
||||
)
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(_RawDevice(clickable_parent), 10)).read_sku_unit_price()
|
||||
@@ -566,6 +639,8 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
root / "src" / "cmbuyer_client" / "pdd" / "sku_selection.py",
|
||||
root / "src" / "cmbuyer_client" / "pdd" / "sku_selection_runner.py",
|
||||
root / "scripts" / "run_t103_sku_selection.py",
|
||||
root / "src" / "cmbuyer_client" / "pdd" / "sku_reveal_spike.py",
|
||||
root / "scripts" / "capture_sku_reveal_spike.py",
|
||||
)
|
||||
forbidden = ("quantity", "confirm", "authorization", "fence", "submit_order", "payment")
|
||||
for path in files:
|
||||
@@ -606,31 +681,56 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
self.assertEqual(_actions(device, "click"), [])
|
||||
|
||||
def test_fixture_contains_no_address_phone_or_payment_credentials(self) -> None:
|
||||
for fixture in (_FIXTURE, _ENTRY_FIXTURE):
|
||||
for fixture in (
|
||||
_EMPTY_FIXTURE,
|
||||
_COLOR_FIXTURE,
|
||||
_S_FIXTURE,
|
||||
_M_FIXTURE,
|
||||
_ENTRY_FIXTURE,
|
||||
):
|
||||
content = fixture.read_text(encoding="utf-8")
|
||||
with self.subTest(fixture=fixture.name):
|
||||
self.assertNotRegex(content, r"1[3-9]\d{9}")
|
||||
for forbidden in ("地址", "收货", "支付", "银行卡", "身份证"):
|
||||
self.assertNotIn(forbidden, content)
|
||||
content = _FIXTURE.read_text(encoding="utf-8")
|
||||
root = ElementTree.fromstring(content)
|
||||
leaf = next(node for node in root.iter("node") if node.get("text") == "提交订单 ¥12.88")
|
||||
self.assertEqual(leaf.get("clickable"), "false")
|
||||
self.assertEqual(leaf.get("bounds"), "[369,2225][710,2284]")
|
||||
self.assertNotIn("提交订单", content)
|
||||
|
||||
|
||||
class _CompletedFlow:
|
||||
"""仅隔离 runner 文件发布测试;生产 Flow 在 reveal 取证前仍必须停止。"""
|
||||
|
||||
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:
|
||||
return None
|
||||
|
||||
def select_sku_options(self, selection: object) -> None:
|
||||
return None
|
||||
|
||||
def verify_target_selection_and_read_price(self, selection: object) -> str:
|
||||
return "12.88"
|
||||
|
||||
def exit_sku_panel_safely(self) -> None:
|
||||
return None
|
||||
|
||||
def reconcile_pending_action(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
def _runner(self, adb: _FakeAdb, device: _RawDevice) -> SkuSelectionRunner:
|
||||
device.hierarchy = "<hierarchy />"
|
||||
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE.replace("<hierarchy>", '<hierarchy post-intent="1">'))
|
||||
return SkuSelectionRunner(adb, lambda serial: device, 10)
|
||||
return SkuSelectionRunner(adb, lambda serial: device, 0.03)
|
||||
|
||||
def test_runner_atomically_publishes_screenshot_and_redacted_manifest(self) -> None:
|
||||
adb = _FakeAdb()
|
||||
device = _RawDevice()
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "result"
|
||||
result = self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||
with patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow):
|
||||
result = self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||
|
||||
self.assertEqual(result.unit_price, "12.88")
|
||||
manifest = result.manifest_path.read_text(encoding="utf-8")
|
||||
@@ -643,7 +743,7 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
self.assertIn('"panel_status": "verified"', manifest)
|
||||
self.assertIn('"safe_exit": "completed"', manifest)
|
||||
self.assertFalse((target / "hierarchy.xml").exists())
|
||||
self.assertEqual(_actions(device, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
|
||||
self.assertEqual(_actions(device, "pressKey"), [])
|
||||
|
||||
def test_failure_stage_is_fixed_control_flow_metadata_without_error_text(self) -> None:
|
||||
class NoPanelAfterEntry(_RawDevice):
|
||||
@@ -729,7 +829,11 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
(Path(destination) / "sentinel").write_text("keep", encoding="utf-8")
|
||||
original_rename(source, destination)
|
||||
|
||||
with patch.object(runner_module.os, "rename", side_effect=create_target_then_rename), self.assertRaises(SkuSelectionRunError):
|
||||
with (
|
||||
patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow),
|
||||
patch.object(runner_module.os, "rename", side_effect=create_target_then_rename),
|
||||
self.assertRaises(SkuSelectionRunError),
|
||||
):
|
||||
self._runner(_FakeAdb(), _RawDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||
self.assertEqual((target / "sentinel").read_text(encoding="utf-8"), "keep")
|
||||
self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
|
||||
@@ -737,14 +841,20 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
def test_bad_screenshot_or_existing_target_never_publishes_manifest(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "result"
|
||||
with self.assertRaises(SkuSelectionScreenshotError) as screenshot_failure:
|
||||
with (
|
||||
patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow),
|
||||
self.assertRaises(SkuSelectionScreenshotError) as screenshot_failure,
|
||||
):
|
||||
self._runner(_FakeAdb(), _RawDevice(screenshot="not-image")).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||
self.assertEqual(safe_failure_stage(screenshot_failure.exception), "screenshot_capture")
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
|
||||
|
||||
target = Path(temporary) / "write-failure"
|
||||
with patch.object(runner_module, "_save_base64_screenshot", side_effect=OSError("private path")):
|
||||
with (
|
||||
patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow),
|
||||
patch.object(runner_module, "_save_base64_screenshot", side_effect=OSError("private path")),
|
||||
):
|
||||
with self.assertRaises(SkuSelectionScreenshotError):
|
||||
self._runner(_FakeAdb(), _RawDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||
self.assertFalse(target.exists())
|
||||
@@ -792,7 +902,11 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
def test_small_but_valid_png_is_not_accepted(self) -> None:
|
||||
image = Image.new("RGB", (1, 1), "white")
|
||||
raw = BytesIO(); image.save(raw, format="PNG")
|
||||
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionScreenshotError):
|
||||
with (
|
||||
TemporaryDirectory() as temporary,
|
||||
patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow),
|
||||
self.assertRaises(SkuSelectionScreenshotError),
|
||||
):
|
||||
self._runner(_FakeAdb(), _RawDevice(screenshot=base64.b64encode(raw.getvalue()).decode("ascii"))).run(
|
||||
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
|
||||
)
|
||||
@@ -907,14 +1021,8 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
with self.assertRaises(SkuSelectionError): SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).exit_sku_panel_safely()
|
||||
self.assertEqual(_actions(device, "pressKey"), [])
|
||||
|
||||
def test_screenshot_then_foreground_drift_publishes_nothing_and_never_back(self) -> None:
|
||||
class ForegroundDriftDevice(_RawDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
value = super().jsonrpc_call(method, params, timeout)
|
||||
if method == "takeScreenshot": self.package = "other"
|
||||
return value
|
||||
|
||||
device = ForegroundDriftDevice()
|
||||
def test_unproven_reveal_publishes_nothing_and_safely_exits_once(self) -> None:
|
||||
device = _RawDevice()
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "out"
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
@@ -922,7 +1030,7 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
self.assertFalse(target.exists())
|
||||
self.assertFalse((target / "manifest.json").exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".out.staging-*")), [])
|
||||
self.assertEqual(_actions(device, "pressKey"), [])
|
||||
self.assertEqual(len(_actions(device, "pressKey")), 1)
|
||||
|
||||
def test_option_timeout_reconciliation_controls_back_once(self) -> None:
|
||||
class OptionTimeoutDevice(_RawDevice):
|
||||
@@ -939,7 +1047,7 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
|
||||
for delivered, expected_back in ((False, 0), (True, 1)):
|
||||
with self.subTest(delivered=delivered), TemporaryDirectory() as temporary:
|
||||
device = OptionTimeoutDevice(delivered); device.select_alternates()
|
||||
device = OptionTimeoutDevice(delivered)
|
||||
adb = _FakeAdb(); device.hierarchy = "<hierarchy />"
|
||||
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE.replace("<hierarchy>", '<hierarchy post-intent="1">'))
|
||||
runner = SkuSelectionRunner(adb, lambda serial: device, .03)
|
||||
@@ -956,9 +1064,9 @@ class SkuSelectionRunnerTests(unittest.TestCase):
|
||||
raise TimeoutError("back uncertain")
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
device = BackTimeoutDevice()
|
||||
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionRunError):
|
||||
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "out")
|
||||
device = BackTimeoutDevice(_M_FIXTURE.read_text(encoding="utf-8"))
|
||||
with self.assertRaises(SkuSelectionRunError):
|
||||
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 0.03)).exit_sku_panel_safely()
|
||||
self.assertEqual(len(_actions(device, "pressKey")), 1)
|
||||
|
||||
def test_entry_click_timeout_reconciles_only_through_verified_flow_exit(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user