feat(client): productionize proven sku reveal flow

This commit is contained in:
QiuSW
2026-08-05 16:55:10 +08:00
parent fec21ae857
commit 60cf05b08b
5 changed files with 895 additions and 487 deletions
+13 -373
View File
@@ -1,4 +1,4 @@
"""T-103 尺码显示动作的一次性真机取证;不属于生产采购 Flow。""" """T-103 尺码显示动作的一次性真机证据采集。"""
from __future__ import annotations from __future__ import annotations
@@ -20,32 +20,10 @@ from ..device.baseline import PDD_PACKAGE, _save_base64_screenshot, _sha256_file
from .product_url import parse_product_url from .product_url import parse_product_url
from .sku_selection import ( from .sku_selection import (
EXPECTED_GOODS_ID, EXPECTED_GOODS_ID,
_COLOR_ONLY_SUMMARY,
_PANEL_SURFACE,
_PanelProfile,
_REVEAL_NOT_PROVEN,
_S_SIZE_UI,
_TARGET_COLOR_UI,
_TARGET_SIZE_UI,
_action_bounds,
_clickable_before,
_descendant,
_exact_color_action,
_exact_inert,
_exact_live_layout,
_exact_readonly_text,
_exact_recycler,
_is_size_action_text,
_one,
_parse_nodes, _parse_nodes,
_require_color_only_panel, _require_color_only_panel,
_require_color_action_chain, _require_safe_reveal_path,
_require_color_subtree, _revealed_unselected_projection,
_require_exact_selected_set,
_require_panel_chain,
_require_size_wrapper,
_require_size_action_chain,
_spec_for,
resolve_task_selection, resolve_task_selection,
SkuSelectionError, SkuSelectionError,
SkuSelectionFlow, SkuSelectionFlow,
@@ -63,9 +41,6 @@ from .sku_selection_runner import (
_TARGET_URL = f"https://mobile.yangkeduo.com/goods.html?goods_id={EXPECTED_GOODS_ID}" _TARGET_URL = f"https://mobile.yangkeduo.com/goods.html?goods_id={EXPECTED_GOODS_ID}"
_REVEAL_START = (360, 1900)
_REVEAL_END = (360, 1300)
_REVEAL_STEPS = 30
_REVEAL_FAILURE_STAGES = frozenset( _REVEAL_FAILURE_STAGES = frozenset(
( (
"reveal_precondition", "reveal_precondition",
@@ -114,30 +89,6 @@ class SkuRevealSpikeResult:
manifest_path: Path manifest_path: Path
class _RevealEvidenceAdapter(UiautomatorSkuPanelAdapter):
"""只为 spike 增加一个无参数、固定 profile 的一次性手势。"""
def __init__(self, device: Any, timeout_seconds: float) -> None:
super().__init__(device, timeout_seconds)
self._reveal_attempted = False
@property
def reveal_attempted(self) -> bool:
return self._reveal_attempted
def reveal_size_options_once(self) -> None:
if self._reveal_attempted:
raise SkuRevealSpikeError("规格显示动作已经尝试过,拒绝重试。")
# RPC 超时也可能表示手势已经送达,必须在调用前封存唯一机会。
self._reveal_attempted = True
self._call(
"jsonrpc_call",
"swipe",
[*_REVEAL_START, *_REVEAL_END, _REVEAL_STEPS],
timeout=self._timeout_seconds,
)
class SkuRevealSpikeCapturer: class SkuRevealSpikeCapturer:
"""打开已取证面板、选一次目标颜色,再采集唯一 reveal 的前后证据。""" """打开已取证面板、选一次目标颜色,再采集唯一 reveal 的前后证据。"""
@@ -165,7 +116,7 @@ class SkuRevealSpikeCapturer:
) -> SkuRevealSpikeResult: ) -> SkuRevealSpikeResult:
stage = "reveal_precondition" stage = "reveal_precondition"
staging: Path | None = None staging: Path | None = None
adapter: _RevealEvidenceAdapter | None = None adapter: UiautomatorSkuPanelAdapter | None = None
try: try:
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID: if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
raise SkuRevealSpikeError("商品不是 T-103 已取证目标,已停止取证。") raise SkuRevealSpikeError("商品不是 T-103 已取证目标,已停止取证。")
@@ -176,7 +127,7 @@ class SkuRevealSpikeCapturer:
deadline = self._clock() + self._timeout_seconds deadline = self._clock() + self._timeout_seconds
inspection = self._adb_client.inspect(serial) inspection = self._adb_client.inspect(serial)
_require_expected_device(inspection) _require_expected_device(inspection)
adapter = _RevealEvidenceAdapter(self._connector(serial), self._timeout_seconds) adapter = UiautomatorSkuPanelAdapter(self._connector(serial), self._timeout_seconds)
_require_expected_version(adapter.app_info(PDD_PACKAGE)) _require_expected_version(adapter.app_info(PDD_PACKAGE))
if adapter.display_size() != EXPECTED_SCREEN_SIZE: if adapter.display_size() != EXPECTED_SCREEN_SIZE:
raise SkuRevealSpikeError("设备不是已取证的竖屏坐标空间,已停止取证。") raise SkuRevealSpikeError("设备不是已取证的竖屏坐标空间,已停止取证。")
@@ -193,13 +144,10 @@ class SkuRevealSpikeCapturer:
sleep_function=self._sleep, sleep_function=self._sleep,
) )
flow.open_sku_panel(link.canonical_url, pre_intent) flow.open_sku_panel(link.canonical_url, pre_intent)
try: # spike 只复用生产 Flow 的私有前置准备,不调用完整选择流程,避免重复 reveal/M。
flow.select_sku_options(resolve_task_selection("黑色CHA(纯棉)", "M(建议100-115)")) flow._prepare_reveal_precondition(
except SkuSelectionError as error: resolve_task_selection("黑色CHA(纯棉)", "M(建议100-115)")
if error.args != (_REVEAL_NOT_PROVEN,): )
raise
else:
raise SkuRevealSpikeError("规格流程未停在已取证的仅颜色状态。")
before_hierarchy = adapter.dump_window_hierarchy() before_hierarchy = adapter.dump_window_hierarchy()
before_nodes = _parse_nodes(before_hierarchy) before_nodes = _parse_nodes(before_hierarchy)
@@ -231,7 +179,7 @@ class SkuRevealSpikeCapturer:
after_directory = staging / "after" after_directory = staging / "after"
_capture_frame(adapter, after_directory, after_hierarchy) _capture_frame(adapter, after_directory, after_hierarchy)
reverified = adapter.dump_window_hierarchy() reverified = adapter.dump_window_hierarchy()
if _candidate_projection(_parse_nodes(reverified)) != projection: if _revealed_unselected_projection(_parse_nodes(reverified)) != projection:
raise SkuRevealSpikeError("截图后候选状态漂移,未发布证据。") raise SkuRevealSpikeError("截图后候选状态漂移,未发布证据。")
(after_directory / "hierarchy.xml").write_text(reverified, encoding="utf-8") (after_directory / "hierarchy.xml").write_text(reverified, encoding="utf-8")
@@ -265,7 +213,7 @@ class SkuRevealSpikeCapturer:
def _wait_for_candidate( def _wait_for_candidate(
self, self,
adapter: _RevealEvidenceAdapter, adapter: UiautomatorSkuPanelAdapter,
deadline: float, deadline: float,
) -> tuple[tuple[tuple[str, ...], ...], str]: ) -> tuple[tuple[tuple[str, ...], ...], str]:
stable: tuple[tuple[str, ...], ...] | None = None stable: tuple[tuple[str, ...], ...] | None = None
@@ -276,7 +224,7 @@ class SkuRevealSpikeCapturer:
raise SkuRevealSpikeError("reveal 后拼多多不在前台,未发布证据。") raise SkuRevealSpikeError("reveal 后拼多多不在前台,未发布证据。")
hierarchy = adapter.dump_window_hierarchy() hierarchy = adapter.dump_window_hierarchy()
try: try:
projection = _candidate_projection(_parse_nodes(hierarchy)) projection = _revealed_unselected_projection(_parse_nodes(hierarchy))
except SkuSelectionError: except SkuSelectionError:
projection = None projection = None
if projection is not None and projection == stable: if projection is not None and projection == stable:
@@ -288,315 +236,7 @@ class SkuRevealSpikeCapturer:
self._sleep(min(0.2, remaining)) self._sleep(min(0.2, remaining))
def _require_safe_reveal_path(nodes: list[Any]) -> None: def _capture_frame(adapter: UiautomatorSkuPanelAdapter, directory: Path, hierarchy: str) -> None:
# 当前真机证据证明 x=360 是两列规格卡之间的空隙;必须验证完整线段,离散采样会漏掉窄浮层。
surface = _one(
[node for node in nodes if _exact_inert(node, "android.view.ViewGroup", _PANEL_SURFACE)],
"固定 reveal 通道无法绑定面板内容面。",
)
_require_panel_chain(surface)
content = surface.parent
action_root = content.parent if content is not None else None
action_parent = action_root.parent if action_root is not None else None
if (
content is None
or action_root is None
or action_parent is None
or not _exact_live_layout(action_root, "android.view.ViewGroup", "[0,366][1080,2328]")
or not _exact_live_layout(action_parent, "android.widget.LinearLayout", "[0,120][1080,2328]")
):
raise SkuRevealSpikeError("固定 reveal 通道祖先身份漂移,未执行手势。")
allowed = {id(action_root.element), id(action_parent.element)}
occupants: set[int] = set()
for node in nodes:
if node.element.get("clickable") != "true":
continue
try:
left, top, right, bottom = _action_bounds(node.bounds)
except SkuSelectionError as error:
raise SkuRevealSpikeError("可点击节点坐标不可验证,未执行手势。") from error
if (
left <= _REVEAL_START[0] < right
and top <= _REVEAL_START[1]
and bottom > _REVEAL_END[1]
):
occupants.add(id(node.element))
if occupants != allowed:
raise SkuRevealSpikeError("固定 reveal 通道被未取证可点击节点占用,未执行手势。")
if _REVEAL_START[1] >= 2079 or _REVEAL_END[1] >= 2079:
raise SkuRevealSpikeError("固定 reveal 通道越过规格内容区,未执行手势。")
def _candidate_projection(nodes: list[Any]) -> tuple[tuple[str, ...], ...]:
surface = _one(
[node for node in nodes if _exact_inert(node, "android.view.ViewGroup", _PANEL_SURFACE)],
"候选面板内容面不唯一。",
)
_require_panel_chain(surface)
header = _one(
[node for node in nodes if node.parent is surface and _exact_inert(node, "android.widget.LinearLayout", "[0,366][1080,1000]")],
"候选面板头部不唯一。",
)
if len([child for child in header.element if child.tag == "node"]) != 4:
raise SkuSelectionError("候选面板头部子节点数量漂移。")
outer = _one(
[node for node in nodes if node.parent is surface and _exact_recycler(node, "[0,1000][1080,2079]")],
"候选维度容器不唯一。",
)
price_content, price_frame = _candidate_price_container(nodes, header)
price_nodes = _candidate_price_nodes(nodes, header, surface, price_frame)
summary = _one(
[
node
for node in nodes
if node.parent is price_content
and _exact_readonly_text(node, _COLOR_ONLY_SUMMARY, "[396,654][1053,716]")
and len(node.element) == 0
],
"候选摘要不唯一。",
)
color_region = _one(
[node for node in nodes if _descendant(node, outer) and _exact_recycler(node, "[36,1000][1080,1483]")],
"候选颜色容器不唯一。",
)
color = _one(
[node for node in nodes if node.parent is color_region and _exact_color_action(node, "[372,1000][684,1024]", True)],
"候选目标颜色不唯一。",
)
rolled_spec = _spec_for(_PanelProfile.TARGETS_SELECTED)
selected_color_nodes = _require_color_subtree(color, rolled_spec)
_require_color_action_chain(color, color_region, outer, surface, rolled_spec)
size_label = _one(
[node for node in nodes if _descendant(node, outer) and _exact_readonly_text(node, "尺码", "[36,1506][114,1552]")],
"候选尺码标题不唯一。",
)
size_header = size_label.parent
if size_header is None or not _exact_live_layout(size_header, "android.widget.LinearLayout", "[36,1489][1044,1570]"):
raise SkuSelectionError("候选尺码标题父结构漂移。")
size_options = _one(
[node for node in nodes if _descendant(node, outer) and _exact_inert(node, "android.view.ViewGroup", "[36,1582][1044,1897]")],
"候选尺码 options 根不唯一。",
)
actions = [node for node in nodes if _descendant(node, size_options) and _is_size_action_text(node)]
s_action = _one(
[node for node in actions if node.text == _S_SIZE_UI and node.bounds == "[36,1582][409,1667]"],
"候选 S action 不唯一。",
)
m_action = _one(
[node for node in actions if node.text == _TARGET_SIZE_UI and node.bounds == "[439,1582][831,1667]"],
"候选 M action 不唯一。",
)
_require_size_wrapper(s_action, size_options)
_require_size_wrapper(m_action, size_options)
_require_size_action_chain(s_action, size_options, outer, surface)
_require_size_action_chain(m_action, size_options, outer, surface)
if any(node.element.get("selected") == "true" for node in actions):
raise SkuSelectionError("reveal 候选态已有尺码被选中。")
_require_exact_selected_set(nodes, surface, selected_color_nodes)
dangerous = [
node for node in nodes
if "提交订单" in node.text
and node.element.get("package") == PDD_PACKAGE
and node.element.get("class") == "android.widget.TextView"
]
if len(dangerous) != 1 or _action_bounds(dangerous[0].bounds)[1] < 2079:
raise SkuSelectionError("提交硬拒绝区位置不唯一。")
return tuple(
(
node.element.get("class", ""),
node.bounds,
node.text,
node.desc,
node.element.get("selected", ""),
node.element.get("clickable", ""),
)
for node in (
surface,
header,
outer,
*price_nodes,
summary,
color_region,
color,
size_label,
size_options,
s_action,
m_action,
dangerous[0],
)
)
def _candidate_price_nodes(
nodes: list[Any],
header: Any,
surface: Any,
price_frame: Any,
) -> tuple[Any, Any, Any]:
matches: list[tuple[Any, Any, Any]] = []
for matcher in (_candidate_dual_price_nodes, _candidate_current_only_price_nodes):
try:
match = matcher(nodes, header, surface, price_frame)
except SkuSelectionError:
continue
matches.append(match)
if len(matches) != 1:
raise SkuSelectionError("候选价格布局不符合唯一完整取证变体。")
return matches[0]
def _candidate_dual_price_nodes(
nodes: list[Any],
header: Any,
surface: Any,
price_frame: Any,
) -> tuple[Any, Any, Any]:
price_row = _one(
[
node
for node in nodes
if node.parent is price_frame
and _exact_inert(node, "android.widget.LinearLayout", "[396,498][895,570]")
],
"候选双价格行不唯一。",
)
if len([child for child in price_row.element if child.tag == "node"]) != 2:
raise SkuSelectionError("候选双价格行子节点数量漂移。")
current = _one(
[
node
for node in nodes
if node.parent is price_row
and _exact_readonly_text(node, "快卖完 ¥12.88", "[396,503][712,570]")
and len(node.element) == 0
],
"候选双价格当前价不唯一。",
)
original = _one(
[
node
for node in nodes
if node.parent is price_row
and _exact_readonly_text(node, "¥29.88", "[730,503][895,570]")
and len(node.element) == 0
],
"候选双价格原价不唯一。",
)
if _clickable_before(current, surface):
raise SkuSelectionError("候选双价格当前价位于可点击内容祖先下。")
if any(
_candidate_current_only_fragment(node)
for node in nodes
if _descendant(node, header)
):
raise SkuSelectionError("候选双价格布局混入单价格残片。")
return price_row, current, original
def _candidate_current_only_price_nodes(
nodes: list[Any],
header: Any,
surface: Any,
price_frame: Any,
) -> tuple[Any, Any, Any]:
price_row = _one(
[
node
for node in nodes
if node.parent is price_frame
and _exact_inert(node, "android.widget.LinearLayout", "[396,498][693,570]")
],
"候选单价格行父子关系漂移。",
)
if len([child for child in price_row.element if child.tag == "node"]) != 1:
raise SkuSelectionError("候选单价格行子节点数量漂移。")
current = _one(
[
node
for node in nodes
if node.parent is price_row
and _exact_readonly_text(node, "限1件 ¥12.88 ", "[396,503][675,570]")
and len(node.element) == 0
],
"候选单价格当前价不唯一。",
)
if any(node.text in {"¥29.88", "券前¥29.88"} for node in nodes):
raise SkuSelectionError("候选单价格变体出现原价角色。")
if any(
_candidate_dual_fragment(node)
for node in nodes
if _descendant(node, header)
):
raise SkuSelectionError("候选单价格布局混入双价格残片。")
if _clickable_before(current, surface):
raise SkuSelectionError("候选单价格当前价位于可点击内容祖先下。")
return price_frame, price_row, current
def _candidate_price_container(nodes: list[Any], header: Any) -> tuple[Any, Any]:
content_frame = _one(
[
node
for node in nodes
if node.parent is header
and _exact_inert(node, "android.widget.FrameLayout", "[0,474][1080,863]")
],
"候选价格内容外框不唯一。",
)
if len([child for child in content_frame.element if child.tag == "node"]) != 1:
raise SkuSelectionError("候选价格内容外框子节点数量漂移。")
relative = _one(
[
node
for node in nodes
if node.parent is content_frame
and _exact_inert(node, "android.widget.RelativeLayout", "[0,474][1080,863]")
],
"候选价格 RelativeLayout 父子关系漂移。",
)
if len([child for child in relative.element if child.tag == "node"]) != 1:
raise SkuSelectionError("候选价格 RelativeLayout 子节点数量漂移。")
content = _one(
[
node
for node in nodes
if node.parent is relative
and _exact_inert(node, "android.view.ViewGroup", "[0,474][1080,863]")
],
"候选价格内容 ViewGroup 父子关系漂移。",
)
if len([child for child in content.element if child.tag == "node"]) != 8:
raise SkuSelectionError("候选价格内容 ViewGroup 子节点数量漂移。")
price_frame = _one(
[
node
for node in nodes
if node.parent is content
and _exact_inert(node, "android.widget.FrameLayout", "[396,498][1053,570]")
],
"候选价格 FrameLayout 父子关系漂移。",
)
if len([child for child in price_frame.element if child.tag == "node"]) != 1:
raise SkuSelectionError("候选价格 FrameLayout 子节点数量漂移。")
return content, price_frame
def _candidate_current_only_fragment(node: Any) -> bool:
return (
_exact_inert(node, "android.widget.LinearLayout", "[396,498][693,570]")
or _exact_readonly_text(node, "限1件 ¥12.88 ", "[396,503][675,570]")
)
def _candidate_dual_fragment(node: Any) -> bool:
return (
_exact_inert(node, "android.widget.LinearLayout", "[396,498][895,570]")
or _exact_readonly_text(node, "快卖完 ¥12.88", "[396,503][712,570]")
or _exact_readonly_text(node, "¥29.88", "[730,503][895,570]")
)
def _capture_frame(adapter: _RevealEvidenceAdapter, directory: Path, hierarchy: str) -> None:
directory.mkdir() directory.mkdir()
hierarchy_path = directory / "hierarchy.xml" hierarchy_path = directory / "hierarchy.xml"
hierarchy_path.write_text(hierarchy, encoding="utf-8") hierarchy_path.write_text(hierarchy, encoding="utf-8")
+361 -62
View File
@@ -18,6 +18,8 @@ EXPECTED_UNIT_PRICE = "12.88"
# 任务值不是页面判据;右侧是 v5 取证的唯一 accessibility 文案(空格/全角括号均有意义)。 # 任务值不是页面判据;右侧是 v5 取证的唯一 accessibility 文案(空格/全角括号均有意义)。
TASK_TO_UI_SELECTION = {("黑色CHA(纯棉)", "M(建议100-115)"): ("黑色 CHA (纯棉)", "M(建议100-115)")} TASK_TO_UI_SELECTION = {("黑色CHA(纯棉)", "M(建议100-115)"): ("黑色 CHA (纯棉)", "M(建议100-115)")}
_TARGET_COLOR_UI, _TARGET_SIZE_UI = next(iter(TASK_TO_UI_SELECTION.values())) _TARGET_COLOR_UI, _TARGET_SIZE_UI = next(iter(TASK_TO_UI_SELECTION.values()))
_TARGET_COLOR_UNROLLED_BOUNDS = "[372,1188][684,1587]"
_TARGET_SIZE_BOUNDS = "[439,1582][831,1667]"
_ENTRY = "快要抢光 ¥ 12.88" _ENTRY = "快要抢光 ¥ 12.88"
_ENTRY_PROMOTION_LABEL = "快要抢光" _ENTRY_PROMOTION_LABEL = "快要抢光"
_ENTRY_TEXT_BOUNDS = "[688,2184][1042,2253]" _ENTRY_TEXT_BOUNDS = "[688,2184][1042,2253]"
@@ -37,10 +39,11 @@ _COLOR_ONLY_SUMMARY = "请选择: 尺码"
_S_SIZE_UI = "S(建议80-100)" _S_SIZE_UI = "S(建议80-100)"
_S_SUMMARY = f"已选: {_TARGET_COLOR_UI} {_S_SIZE_UI}" _S_SUMMARY = f"已选: {_TARGET_COLOR_UI} {_S_SIZE_UI}"
_TARGET_SUMMARY = f"已选: {_TARGET_COLOR_UI} {_TARGET_SIZE_UI}" _TARGET_SUMMARY = f"已选: {_TARGET_COLOR_UI} {_TARGET_SIZE_UI}"
_REVEAL_NOT_PROVEN = "尺码仍在已取证视口外;受控显示动作尚未取证,已停止后续点击。"
_BOUNDS = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$") _BOUNDS = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$")
_ROLLED_CURRENT_PRICE = "快卖完 ¥12.88" _ROLLED_CURRENT_PRICE = "快卖完 ¥12.88"
_ROLLED_ORIGINAL_PRICE = "¥29.88" _ROLLED_ORIGINAL_PRICE = "¥29.88"
_REVEALED_CURRENT_PRICE = "限1件 ¥12.88 "
_REVEAL_GESTURE = (360, 1900, 360, 1300, 30)
_BAD_PRICE_ROLE = ("提交订单", "支付", "优惠", "券", "会员", "补贴", "区间", "实付", "到手", "原价", "划线价", "最低", "低至", "起价", "下单", "先用后付", "预估") _BAD_PRICE_ROLE = ("提交订单", "支付", "优惠", "券", "会员", "补贴", "区间", "实付", "到手", "原价", "划线价", "最低", "低至", "起价", "下单", "先用后付", "预估")
@@ -91,6 +94,7 @@ class SkuPanelDevice(Protocol):
def dump_window_hierarchy(self) -> str: ... def dump_window_hierarchy(self) -> str: ...
def tap_sku_entry(self, bounds: str) -> None: ... def tap_sku_entry(self, bounds: str) -> None: ...
def tap_sku_option(self, bounds: str) -> None: ... def tap_sku_option(self, bounds: str) -> None: ...
def reveal_size_options_once(self) -> None: ...
def leave_sku_panel(self) -> None: ... def leave_sku_panel(self) -> None: ...
@@ -122,6 +126,7 @@ class _Node:
class _PanelProfile(Enum): class _PanelProfile(Enum):
PANEL_OPEN_EMPTY = "panel_open_empty" PANEL_OPEN_EMPTY = "panel_open_empty"
COLOR_SELECTED_SIZE_HIDDEN = "color_selected_size_hidden" COLOR_SELECTED_SIZE_HIDDEN = "color_selected_size_hidden"
SIZE_VISIBLE_UNSELECTED = "size_visible_unselected"
SIZE_VISIBLE_NON_TARGET = "size_visible_non_target" SIZE_VISIBLE_NON_TARGET = "size_visible_non_target"
TARGETS_SELECTED = "targets_selected" TARGETS_SELECTED = "targets_selected"
@@ -172,7 +177,7 @@ _PANEL_SPECS = (
"券前¥29.88", "[693,580][912,647]", "券前¥29.88", "[693,580][912,647]",
), ),
_EMPTY_SUMMARY, "[396,731][1053,793]", "[36,1106][192,1159]", _EMPTY_SUMMARY, "[396,731][1053,793]", "[36,1106][192,1159]",
"[36,1188][1080,2046]", "[372,1188][684,1587]", False, "[36,1188][1080,2046]", _TARGET_COLOR_UNROLLED_BOUNDS, False,
"[36,2069][114,2079]", None, "[36,2069][114,2079]", None,
), ),
_PanelSpec( _PanelSpec(
@@ -183,7 +188,7 @@ _PANEL_SPECS = (
"券前¥29.88", "[693,580][912,647]", "券前¥29.88", "[693,580][912,647]",
), ),
_COLOR_ONLY_SUMMARY, "[396,731][1053,793]", "[36,1106][192,1159]", _COLOR_ONLY_SUMMARY, "[396,731][1053,793]", "[36,1106][192,1159]",
"[36,1188][1080,2046]", "[372,1188][684,1587]", True, "[36,1188][1080,2046]", _TARGET_COLOR_UNROLLED_BOUNDS, True,
"[36,2069][114,2079]", None, "[36,2069][114,2079]", None,
), ),
_PanelSpec( _PanelSpec(
@@ -194,7 +199,7 @@ _PANEL_SPECS = (
"限1件 ¥12.88 ", "[396,580][675,647]", "限1件 ¥12.88 ", "[396,580][675,647]",
), ),
_EMPTY_SUMMARY, "[396,731][1053,793]", "[36,1106][192,1159]", _EMPTY_SUMMARY, "[396,731][1053,793]", "[36,1106][192,1159]",
"[36,1188][1080,2046]", "[372,1188][684,1587]", False, "[36,1188][1080,2046]", _TARGET_COLOR_UNROLLED_BOUNDS, False,
"[36,2069][114,2079]", None, "[36,2069][114,2079]", None,
), ),
_PanelSpec( _PanelSpec(
@@ -205,7 +210,7 @@ _PANEL_SPECS = (
"限1件 ¥12.88 ", "[396,580][675,647]", "限1件 ¥12.88 ", "[396,580][675,647]",
), ),
_COLOR_ONLY_SUMMARY, "[396,731][1053,793]", "[36,1106][192,1159]", _COLOR_ONLY_SUMMARY, "[396,731][1053,793]", "[36,1106][192,1159]",
"[36,1188][1080,2046]", "[372,1188][684,1587]", True, "[36,1188][1080,2046]", _TARGET_COLOR_UNROLLED_BOUNDS, True,
"[36,2069][114,2079]", None, "[36,2069][114,2079]", None,
), ),
_PanelSpec( _PanelSpec(
@@ -233,6 +238,19 @@ _PANEL_SPECS = (
) )
_REVEALED_UNSELECTED_SPEC = _PanelSpec(
_PanelProfile.SIZE_VISIBLE_UNSELECTED,
"[0,366][1080,1000]", "[0,1000][1080,2079]",
_CurrentOnlyPriceLayout(
"[396,498][1053,570]", "[396,498][693,570]",
_REVEALED_CURRENT_PRICE, "[396,503][675,570]",
),
_COLOR_ONLY_SUMMARY, "[396,654][1053,716]", None,
"[36,1000][1080,1483]", "[372,1000][684,1024]", True,
"[36,1506][114,1552]", None,
)
class SkuSelectionFlow: class SkuSelectionFlow:
def __init__(self, device: SkuPanelDevice, entry_wait_timeout_seconds: float = 0.2, def __init__(self, device: SkuPanelDevice, entry_wait_timeout_seconds: float = 0.2,
entry_poll_interval_seconds: float = 0.2, monotonic_clock: Callable[[], float] = monotonic, entry_poll_interval_seconds: float = 0.2, monotonic_clock: Callable[[], float] = monotonic,
@@ -241,9 +259,12 @@ class SkuSelectionFlow:
raise ValueError("入口等待参数无效。") raise ValueError("入口等待参数无效。")
self._device, self._entry_timeout, self._poll = device, entry_wait_timeout_seconds, entry_poll_interval_seconds self._device, self._entry_timeout, self._poll = device, entry_wait_timeout_seconds, entry_poll_interval_seconds
self._clock, self._sleep = monotonic_clock, sleep_function self._clock, self._sleep = monotonic_clock, sleep_function
self._pending: tuple[str, Callable[[list[_Node]], Any]] | None = None self._pending: tuple[str, Callable[[list[_Node]], Any], bool] | None = None
self._terminal = False
self._color_selected_by_flow = False
def open_sku_panel(self, product_url: str, pre_intent_hierarchy: str | None = None) -> None: def open_sku_panel(self, product_url: str, pre_intent_hierarchy: str | None = None) -> None:
self._require_active()
try: try:
if parse_product_url(product_url).goods_id != EXPECTED_GOODS_ID: if parse_product_url(product_url).goods_id != EXPECTED_GOODS_ID:
raise SkuSelectionError("商品不是已取证目标,已停止操作。") raise SkuSelectionError("商品不是已取证目标,已停止操作。")
@@ -265,90 +286,175 @@ class SkuSelectionFlow:
raise raise
try: try:
self._pending = (before, _require_empty_panel) self._pending = (before, _require_empty_panel, False)
self._device.tap_sku_entry(entry.bounds) self._device.tap_sku_entry(entry.bounds)
except BaseException as error: except BaseException as error:
self._terminal = True
_annotate_sku_entry_failure(error, "sku_entry_click") _annotate_sku_entry_failure(error, "sku_entry_click")
raise raise
try: try:
self._wait_after_action(before, _require_empty_panel) self._wait_after_action(before, _require_empty_panel)
except BaseException as error: except BaseException as error:
self._terminal = True
_annotate_sku_entry_failure(error, "sku_entry_panel_verify") _annotate_sku_entry_failure(error, "sku_entry_panel_verify")
raise raise
def select_sku_options(self, selection: SkuSelection) -> None: def select_sku_options(self, selection: SkuSelection) -> None:
self._require_active()
if selection != SkuSelection(_TARGET_COLOR_UI, _TARGET_SIZE_UI):
raise SkuSelectionError("规格 UI 文案不是获准目标,已停止操作。")
nodes = self._verified_nodes()
initial_profile = _classify_panel(nodes).profile
if initial_profile is _PanelProfile.TARGETS_SELECTED:
return
if initial_profile is _PanelProfile.SIZE_VISIBLE_NON_TARGET:
self._require_foreground()
before = self._read_hierarchy()
nodes = _parse_nodes(before)
target = _target_size_action(nodes, selected=False)
_action_bounds(target.bounds)
_require_action_occupants(nodes, target)
self._perform_mutation(
before,
_require_target_panel,
partial(self._device.tap_sku_option, target.bounds),
)
return
self._prepare_reveal_precondition(selection)
self._require_foreground()
before = self._read_hierarchy()
nodes = _parse_nodes(before)
spec = _require_profile(nodes, _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN)
if not isinstance(spec.price_layout, _CurrentOnlyPriceLayout):
# 真机动作证据只覆盖 current-only;dual 仅颜色态必须在 reveal 前零动作停止。
raise SkuSelectionError("规格显示前置不是已取证单价格布局,已停止操作。")
_require_safe_reveal_path(nodes)
self._perform_mutation(
before,
_revealed_unselected_projection,
self._device.reveal_size_options_once,
require_stable=True,
)
self._require_foreground()
before = self._read_hierarchy()
nodes = _parse_nodes(before)
_revealed_unselected_projection(nodes)
target = _target_size_action(nodes, selected=False)
_action_bounds(target.bounds)
_require_action_occupants(nodes, target)
self._perform_mutation(
before,
_require_target_panel,
partial(self._device.tap_sku_option, target.bounds),
)
def _prepare_reveal_precondition(self, selection: SkuSelection) -> None:
"""只把 current-only 初态推进到仅颜色态,供生产 Flow 与证据 spike 共用。"""
self._require_active()
if selection != SkuSelection(_TARGET_COLOR_UI, _TARGET_SIZE_UI): if selection != SkuSelection(_TARGET_COLOR_UI, _TARGET_SIZE_UI):
raise SkuSelectionError("规格 UI 文案不是获准目标,已停止操作。") raise SkuSelectionError("规格 UI 文案不是获准目标,已停止操作。")
self._require_foreground() self._require_foreground()
before = self._read_hierarchy() before = self._read_hierarchy()
nodes = _parse_nodes(before) nodes = _parse_nodes(before)
spec = _classify_panel(nodes) spec = _classify_panel(nodes)
profile = spec.profile if spec.profile is _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN:
if not isinstance(spec.price_layout, _CurrentOnlyPriceLayout):
if profile is _PanelProfile.PANEL_OPEN_EMPTY: raise SkuSelectionError("规格显示前置不是已取证单价格布局,已停止操作。")
target = _target_color_action(nodes, selected=False) if not self._color_selected_by_flow:
_action_bounds(target.bounds) raise SkuSelectionError("目标颜色不是本次 Flow 精确选中,未执行 reveal。")
_require_action_occupants(nodes, target)
color_postcondition = partial(
_require_profile_with_price_layout,
expected=_PanelProfile.COLOR_SELECTED_SIZE_HIDDEN,
expected_price_layout=spec.price_layout,
)
self._pending = (before, color_postcondition)
self._device.tap_sku_option(target.bounds)
self._wait_after_action(before, color_postcondition)
# 当前证据只证明颜色选择;尺码仍在视口外。没有动作证据时必须在此停住,
# 不能把一次通用 swipe 或下一次规格点击伪装成已验证流程。
raise SkuSelectionError(_REVEAL_NOT_PROVEN)
if profile is _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN:
raise SkuSelectionError(_REVEAL_NOT_PROVEN)
if profile is _PanelProfile.SIZE_VISIBLE_NON_TARGET:
target = _target_size_action(nodes, selected=False)
_action_bounds(target.bounds)
_require_action_occupants(nodes, target)
self._pending = (before, _require_target_panel)
self._device.tap_sku_option(target.bounds)
self._wait_after_action(before, _require_target_panel)
return return
if profile is _PanelProfile.TARGETS_SELECTED: if spec.profile is not _PanelProfile.PANEL_OPEN_EMPTY or not isinstance(
return spec.price_layout, _CurrentOnlyPriceLayout
raise SkuSelectionError("规格面板状态不属于已取证 profile,已停止操作。") ):
raise SkuSelectionError("规格面板不属于获准的单价格初态,已停止操作。")
target = _target_color_action(nodes, selected=False)
_action_bounds(target.bounds)
_require_action_occupants(nodes, target)
color_postcondition = partial(
_require_profile_with_price_layout,
expected=_PanelProfile.COLOR_SELECTED_SIZE_HIDDEN,
expected_price_layout=spec.price_layout,
)
self._perform_mutation(
before,
color_postcondition,
partial(self._device.tap_sku_option, target.bounds),
)
self._color_selected_by_flow = True
def read_sku_unit_price(self) -> str: def read_sku_unit_price(self) -> str:
self._require_active()
return _unit_price(self._verified_nodes()) return _unit_price(self._verified_nodes())
def verify_target_selection_and_read_price(self, selection: SkuSelection) -> str: def verify_target_selection_and_read_price(self, selection: SkuSelection) -> str:
self._require_active()
if selection != SkuSelection(_TARGET_COLOR_UI, _TARGET_SIZE_UI): if selection != SkuSelection(_TARGET_COLOR_UI, _TARGET_SIZE_UI):
raise SkuSelectionError("规格 UI 文案不是获准目标,已停止读取。") raise SkuSelectionError("规格 UI 文案不是获准目标,已停止读取。")
nodes = self._verified_nodes() nodes = self._verified_nodes()
return _unit_price(nodes) return _unit_price(nodes)
def exit_sku_panel_safely(self) -> None: def exit_sku_panel_safely(self) -> None:
self._require_active()
self._require_foreground() self._require_foreground()
before = self._read_hierarchy() before = self._read_hierarchy()
_classify_panel(_parse_nodes(before)) # 截图重证与 Back 之间仍可能漂移;只有目标双规格和已验单价同时保持时才允许返回。
self._device.leave_sku_panel() _unit_price(_parse_nodes(before))
try:
self._device.leave_sku_panel()
except BaseException:
self._terminal = True
raise
deadline = self._clock() + self._entry_timeout deadline = self._clock() + self._entry_timeout
while True: try:
self._require_foreground() while True:
raw = self._read_hierarchy() self._require_foreground()
if raw != before: raw = self._read_hierarchy()
try: if raw != before:
_classify_panel(_parse_nodes(raw)) try:
except SkuSelectionError: _classify_panel(_parse_nodes(raw))
return except SkuSelectionError:
remaining = deadline - self._clock() self._terminal = True
if remaining <= 0: return
raise SkuSelectionError("安全退出后未确认离开规格面板,未重试返回。") remaining = deadline - self._clock()
self._sleep(min(self._poll, remaining)) if remaining <= 0:
raise SkuSelectionError("安全退出后未确认离开规格面板,未重试返回。")
self._sleep(min(self._poll, remaining))
except BaseException:
self._terminal = True
raise
def reconcile_pending_action(self) -> None: def reconcile_pending_action(self) -> None:
"""仅只读调和一次已发出但尚未得到后置条件确认的动作。""" """仅只读调和一次已发出但尚未得到后置条件确认的动作。"""
if self._pending is None: if self._pending is None:
return return
before, condition = self._pending before, condition, require_stable = self._pending
self._wait_after_action(before, condition) self._wait_after_action(before, condition, require_stable=require_stable)
def _perform_mutation(
self,
before: str,
condition: Callable[[list[_Node]], Any],
mutation: Callable[[], None],
*,
require_stable: bool = False,
) -> list[_Node]:
# 后置条件连同唯一动作机会必须在 RPC 前封存;超时可能表示已送达,不能重发。
self._pending = (before, condition, require_stable)
try:
mutation()
return self._wait_after_action(before, condition, require_stable=require_stable)
except BaseException:
# 结果不明时只允许 reconcile_pending_action 读回;即使调和成功,本 Flow 也不再继续。
self._terminal = True
raise
def _require_active(self) -> None:
if self._terminal:
raise SkuSelectionError("规格流程已进入不可重入终止态,已停止操作。")
def _wait_for_entry(self, previous: str | None) -> tuple[_Node, str]: def _wait_for_entry(self, previous: str | None) -> tuple[_Node, str]:
deadline, stable = self._clock() + self._entry_timeout, None deadline, stable = self._clock() + self._entry_timeout, None
@@ -380,19 +486,31 @@ class SkuSelectionFlow:
raise SkuSelectionError("等待已取证规格入口超时,未执行点击。") raise SkuSelectionError("等待已取证规格入口超时,未执行点击。")
self._sleep(min(self._poll, remaining)) self._sleep(min(self._poll, remaining))
def _wait_after_action(self, previous: str, condition: Callable[[list[_Node]], Any]) -> list[_Node]: def _wait_after_action(
self,
previous: str,
condition: Callable[[list[_Node]], Any],
*,
require_stable: bool = False,
) -> list[_Node]:
deadline = self._clock() + self._entry_timeout deadline = self._clock() + self._entry_timeout
stable: Any = None
has_stable = False
while True: while True:
self._require_foreground() self._require_foreground()
raw = self._read_hierarchy() raw = self._read_hierarchy()
if raw != previous: if raw != previous:
nodes = _parse_nodes(raw) nodes = _parse_nodes(raw)
try: try:
condition(nodes) projection = condition(nodes)
self._pending = None if not require_stable or (has_stable and projection == stable):
return nodes self._pending = None
return nodes
stable = projection
has_stable = True
except SkuSelectionError: except SkuSelectionError:
pass stable = None
has_stable = False
remaining = deadline - self._clock() remaining = deadline - self._clock()
if remaining <= 0: if remaining <= 0:
raise SkuSelectionError("动作后页面未在限定时间内满足已取证后置条件,未重试动作。") raise SkuSelectionError("动作后页面未在限定时间内满足已取证后置条件,未重试动作。")
@@ -445,6 +563,12 @@ def _classify_panel(nodes: list[_Node]) -> _PanelSpec:
except SkuSelectionError: except SkuSelectionError:
continue continue
matches.append(spec) matches.append(spec)
try:
_revealed_unselected_projection(nodes)
except SkuSelectionError:
pass
else:
matches.append(_REVEALED_UNSELECTED_SPEC)
if len(matches) != 1: if len(matches) != 1:
raise SkuSelectionError("规格面板不符合唯一完整取证 profile,已停止操作。") raise SkuSelectionError("规格面板不符合唯一完整取证 profile,已停止操作。")
return matches[0] return matches[0]
@@ -574,6 +698,177 @@ def _match_panel_profile(nodes: list[_Node], spec: _PanelSpec) -> None:
_require_exact_selected_set(nodes, surface, [*selected_nodes, expected_action]) _require_exact_selected_set(nodes, surface, [*selected_nodes, expected_action])
def _require_safe_reveal_path(nodes: list[_Node]) -> None:
"""证明固定手势整条线段仍是已取证的两列卡片间安全通道。"""
surface = _one(
[node for node in nodes if _exact_inert(node, "android.view.ViewGroup", _PANEL_SURFACE)],
"固定 reveal 通道无法绑定面板内容面。",
)
_require_panel_chain(surface)
content = surface.parent
action_root = content.parent if content is not None else None
action_parent = action_root.parent if action_root is not None else None
if (
content is None
or action_root is None
or action_parent is None
or not _exact_live_layout(action_root, "android.view.ViewGroup", "[0,366][1080,2328]")
or not _exact_live_layout(action_parent, "android.widget.LinearLayout", "[0,120][1080,2328]")
):
raise SkuSelectionError("固定 reveal 通道祖先身份漂移,未执行手势。")
start_x, start_y, end_x, end_y, _steps = _REVEAL_GESTURE
allowed = {id(action_root.element), id(action_parent.element)}
occupants: set[int] = set()
for node in nodes:
if node.element.get("clickable") != "true":
continue
try:
left, top, right, bottom = _action_bounds(node.bounds)
except SkuSelectionError as error:
raise SkuSelectionError("可点击节点坐标不可验证,未执行手势。") from error
if (
left <= start_x < right
and top <= start_y
and bottom > end_y
):
occupants.add(id(node.element))
if occupants != allowed:
raise SkuSelectionError("固定 reveal 通道被未取证可点击节点占用,未执行手势。")
if start_y >= 2079 or end_y >= 2079:
raise SkuSelectionError("固定 reveal 通道越过规格内容区,未执行手势。")
def _revealed_unselected_projection(nodes: list[_Node]) -> tuple[tuple[str, ...], ...]:
"""唯一生产判据:current-only reveal 后,目标颜色已选且 S/M 均未选。"""
surface = _one(
[node for node in nodes if _exact_inert(node, "android.view.ViewGroup", _PANEL_SURFACE)],
"reveal 后面板内容面不唯一。",
)
_require_panel_chain(surface)
header = _one(
[node for node in nodes if node.parent is surface and _exact_inert(node, "android.widget.LinearLayout", "[0,366][1080,1000]")],
"reveal 后面板头部不唯一。",
)
if len([child for child in header.element if child.tag == "node"]) != 4:
raise SkuSelectionError("reveal 后面板头部子节点数量漂移。")
outer = _one(
[node for node in nodes if node.parent is surface and _exact_recycler(node, "[0,1000][1080,2079]")],
"reveal 后维度容器不唯一。",
)
content_frame = _one(
[node for node in nodes if node.parent is header and _exact_inert(node, "android.widget.FrameLayout", "[0,474][1080,863]")],
"reveal 后价格内容外框不唯一。",
)
if len([child for child in content_frame.element if child.tag == "node"]) != 1:
raise SkuSelectionError("reveal 后价格内容外框子节点数量漂移。")
relative = _one(
[node for node in nodes if node.parent is content_frame and _exact_inert(node, "android.widget.RelativeLayout", "[0,474][1080,863]")],
"reveal 后价格 RelativeLayout 父子关系漂移。",
)
if len([child for child in relative.element if child.tag == "node"]) != 1:
raise SkuSelectionError("reveal 后价格 RelativeLayout 子节点数量漂移。")
content = _one(
[node for node in nodes if node.parent is relative and _exact_inert(node, "android.view.ViewGroup", "[0,474][1080,863]")],
"reveal 后价格内容 ViewGroup 父子关系漂移。",
)
if len([child for child in content.element if child.tag == "node"]) != 8:
raise SkuSelectionError("reveal 后价格内容 ViewGroup 子节点数量漂移。")
price_frame = _one(
[node for node in nodes if node.parent is content and _exact_inert(node, "android.widget.FrameLayout", "[396,498][1053,570]")],
"reveal 后价格 FrameLayout 父子关系漂移。",
)
if len([child for child in price_frame.element if child.tag == "node"]) != 1:
raise SkuSelectionError("reveal 后价格 FrameLayout 子节点数量漂移。")
price_row = _one(
[node for node in nodes if node.parent is price_frame and _exact_inert(node, "android.widget.LinearLayout", "[396,498][693,570]")],
"reveal 后单价格行父子关系漂移。",
)
if len([child for child in price_row.element if child.tag == "node"]) != 1:
raise SkuSelectionError("reveal 后单价格行子节点数量漂移。")
current = _one(
[node for node in nodes if node.parent is price_row and _exact_readonly_text(node, _REVEALED_CURRENT_PRICE, "[396,503][675,570]") and len(node.element) == 0],
"reveal 后单价格当前价不唯一。",
)
if _clickable_before(current, surface):
raise SkuSelectionError("reveal 后单价格当前价位于可点击内容祖先下。")
if any(
node.text in {_ROLLED_CURRENT_PRICE, _ROLLED_ORIGINAL_PRICE, "券前¥29.88"}
or (node.bounds == "[396,498][895,570]" and node.element.get("class") == "android.widget.LinearLayout")
for node in nodes
):
raise SkuSelectionError("reveal 后单价格布局混入双价格角色。")
summary = _one(
[node for node in nodes if node.parent is content and _exact_readonly_text(node, _COLOR_ONLY_SUMMARY, "[396,654][1053,716]") and len(node.element) == 0],
"reveal 后摘要不唯一。",
)
color_region = _one(
[node for node in nodes if _descendant(node, outer) and _exact_recycler(node, "[36,1000][1080,1483]")],
"reveal 后颜色容器不唯一。",
)
color = _one(
[node for node in nodes if node.parent is color_region and _exact_color_action(node, "[372,1000][684,1024]", True)],
"reveal 后目标颜色不唯一。",
)
selected_color_nodes = _require_color_subtree(color, _REVEALED_UNSELECTED_SPEC)
_require_color_action_chain(color, color_region, outer, surface, _REVEALED_UNSELECTED_SPEC)
size_label = _one(
[node for node in nodes if _descendant(node, outer) and _exact_readonly_text(node, _SIZE, "[36,1506][114,1552]")],
"reveal 后尺码标题不唯一。",
)
size_header = size_label.parent
if size_header is None or not _exact_live_layout(size_header, "android.widget.LinearLayout", "[36,1489][1044,1570]"):
raise SkuSelectionError("reveal 后尺码标题父结构漂移。")
size_options = _one(
[node for node in nodes if _descendant(node, outer) and _exact_inert(node, "android.view.ViewGroup", "[36,1582][1044,1897]")],
"reveal 后尺码 options 根不唯一。",
)
actions = [node for node in nodes if _descendant(node, size_options) and _is_size_action_text(node)]
s_action = _one(
[node for node in actions if node.text == _S_SIZE_UI and node.bounds == "[36,1582][409,1667]" and node.element.get("selected") == "false"],
"reveal 后 S action 不唯一。",
)
m_action = _one(
[node for node in actions if node.text == _TARGET_SIZE_UI and node.bounds == _TARGET_SIZE_BOUNDS and node.element.get("selected") == "false"],
"reveal 后 M action 不唯一。",
)
_require_size_wrapper(s_action, size_options)
_require_size_wrapper(m_action, size_options)
_require_size_action_chain(s_action, size_options, outer, surface)
_require_size_action_chain(m_action, size_options, outer, surface)
if any(node.element.get("selected") == "true" for node in actions):
raise SkuSelectionError("reveal 后已有尺码被选中。")
_require_exact_selected_set(nodes, surface, selected_color_nodes)
dangerous = [
node
for node in nodes
if node.element.get("package") == PDD_PACKAGE
and node.element.get("class") == "android.widget.TextView"
and "提交订单" in node.text
]
danger = _one(dangerous, "提交硬拒绝区位置不唯一。")
if (
not _exact_readonly_text(danger, "选择尺码后,提交订单", "[285,2225][795,2284]")
or len(danger.element) != 0
):
raise SkuSelectionError("提交硬拒绝区结构漂移。")
return tuple(
(
node.element.get("class", ""),
node.bounds,
node.text,
node.desc,
node.element.get("selected", ""),
node.element.get("clickable", ""),
)
for node in (
surface, header, outer, price_frame, price_row, current, summary,
color_region, color, size_label, size_options, s_action, m_action, danger,
)
)
def _unit_price(nodes: list[_Node]) -> str: def _unit_price(nodes: list[_Node]) -> str:
spec = _classify_panel(nodes) spec = _classify_panel(nodes)
if spec.profile is not _PanelProfile.TARGETS_SELECTED: if spec.profile is not _PanelProfile.TARGETS_SELECTED:
@@ -601,11 +896,15 @@ def _target_color_action(nodes: list[_Node], *, selected: bool) -> _Node:
def _target_size_action(nodes: list[_Node], *, selected: bool) -> _Node: def _target_size_action(nodes: list[_Node], *, selected: bool) -> _Node:
profile = _classify_panel(nodes).profile profile = _classify_panel(nodes).profile
expected_profile = _PanelProfile.TARGETS_SELECTED if selected else _PanelProfile.SIZE_VISIBLE_NON_TARGET expected_profiles = (
if profile is not expected_profile: {_PanelProfile.TARGETS_SELECTED}
if selected
else {_PanelProfile.SIZE_VISIBLE_UNSELECTED, _PanelProfile.SIZE_VISIBLE_NON_TARGET}
)
if profile not in expected_profiles:
raise SkuSelectionError("目标尺码 action 不属于预期 profile。") raise SkuSelectionError("目标尺码 action 不属于预期 profile。")
return _one( return _one(
[node for node in nodes if _is_size_action_text(node) and node.text == _TARGET_SIZE_UI and node.bounds == "[439,1582][831,1667]" and node.element.get("selected") == str(selected).lower()], [node for node in nodes if _is_size_action_text(node) and node.text == _TARGET_SIZE_UI and node.bounds == _TARGET_SIZE_BOUNDS and node.element.get("selected") == str(selected).lower()],
"目标尺码 action 不唯一。", "目标尺码 action 不唯一。",
) )
@@ -825,7 +1124,7 @@ def _require_color_subtree(color: _Node, spec: _PanelSpec) -> list[_Node]:
selected = str(spec.color_selected).lower() selected = str(spec.color_selected).lower()
children = [child for child in color.element if child.tag == "node"] children = [child for child in color.element if child.tag == "node"]
expected_selected: list[_Node] = [color] if spec.color_selected else [] expected_selected: list[_Node] = [color] if spec.color_selected else []
if spec.color_bounds == "[372,1188][684,1587]": if spec.color_bounds == _TARGET_COLOR_UNROLLED_BOUNDS:
if len(children) != 4: if len(children) != 4:
raise SkuSelectionError("目标颜色完整卡片子树数量漂移。") raise SkuSelectionError("目标颜色完整卡片子树数量漂移。")
child_nodes = [node for node in _walk_direct_children(color)] child_nodes = [node for node in _walk_direct_children(color)]
@@ -869,7 +1168,7 @@ def _require_color_action_chain(
) -> None: ) -> None:
if color.parent is not color_region: if color.parent is not color_region:
raise SkuSelectionError("目标颜色 action 不属于已取证颜色容器。") raise SkuSelectionError("目标颜色 action 不属于已取证颜色容器。")
if spec.color_bounds == "[372,1188][684,1587]": if spec.color_bounds == _TARGET_COLOR_UNROLLED_BOUNDS:
expected = ( expected = (
("android.widget.FrameLayout", "[0,1188][1080,2046]"), ("android.widget.FrameLayout", "[0,1188][1080,2046]"),
("android.widget.LinearLayout", "[0,1188][1080,2052]"), ("android.widget.LinearLayout", "[0,1188][1080,2052]"),
@@ -31,6 +31,10 @@ from .sku_selection import (
SkuSelectionError, SkuSelectionError,
SkuSelectionFlow, SkuSelectionFlow,
_SKU_ENTRY_FAILURE_STAGES, _SKU_ENTRY_FAILURE_STAGES,
_REVEAL_GESTURE,
_ENTRY_TEXT_BOUNDS,
_TARGET_COLOR_UNROLLED_BOUNDS,
_TARGET_SIZE_BOUNDS,
_action_bounds, _action_bounds,
_annotate_sku_entry_failure, _annotate_sku_entry_failure,
_safe_sku_entry_failure_stage, _safe_sku_entry_failure_stage,
@@ -135,9 +139,9 @@ class SkuSelectionRunResult:
class UiautomatorSkuPanelAdapter(SkuPanelDevice): class UiautomatorSkuPanelAdapter(SkuPanelDevice):
"""把 uiautomator2 缩为 T-103 所需的读取与三种命名操作。 """把 uiautomator2 缩为 T-103 所需的读取与四种命名操作。
``tap_sku_entry``、``tap_sku_option`` 和 ``leave_sku_panel`` 是仅有的状态改变方法; 四个命名方法是仅有的状态改变入口;reveal 的手势参数固定且不向 Flow 暴露;
坐标由 Flow 和本类双重检查后才计算中心点,每次调用只执行一次底层动作。 坐标由 Flow 和本类双重检查后才计算中心点,每次调用只执行一次底层动作。
""" """
@@ -147,7 +151,14 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
self._device = device self._device = device
self._timeout_seconds = timeout_seconds self._timeout_seconds = timeout_seconds
self._entry_was_tapped = False self._entry_was_tapped = False
self._entry_bounds_attempted: str | None = None
self._entry_rpc_outcome = "not_attempted"
self._option_bounds_attempted: set[str] = set()
self._option_rpc_outcomes: dict[str, str] = {}
self._reveal_attempted = False
self._reveal_rpc_outcome = "not_attempted"
self._left_panel = False self._left_panel = False
self._back_rpc_outcome = "not_attempted"
@property @property
def entry_was_tapped(self) -> bool: def entry_was_tapped(self) -> bool:
@@ -159,6 +170,38 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
def left_panel(self) -> bool: def left_panel(self) -> bool:
return self._left_panel return self._left_panel
@property
def reveal_attempted(self) -> bool:
return self._reveal_attempted
@property
def entry_rpc_outcome(self) -> str:
return self._entry_rpc_outcome
@property
def entry_bounds_attempted(self) -> str | None:
return self._entry_bounds_attempted
@property
def option_rpc_outcomes(self) -> tuple[tuple[str, str], ...]:
return tuple(sorted(self._option_rpc_outcomes.items()))
@property
def reveal_rpc_outcome(self) -> str:
return self._reveal_rpc_outcome
@property
def option_attempts(self) -> int:
return len(self._option_bounds_attempted)
@property
def back_attempts(self) -> int:
return int(self._left_panel)
@property
def back_rpc_outcome(self) -> str:
return self._back_rpc_outcome
def app_info(self, package_name: str) -> dict[str, Any]: def app_info(self, package_name: str) -> dict[str, Any]:
value = self._call("app_info", package_name) value = self._call("app_info", package_name)
if not isinstance(value, dict): if not isinstance(value, dict):
@@ -178,19 +221,46 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
return value return value
def tap_sku_entry(self, bounds: str) -> None: def tap_sku_entry(self, bounds: str) -> None:
if self._entry_was_tapped:
raise SkuSelectionDeviceAdapterError("规格入口已经尝试过,拒绝重试。")
# 超时也可能表示底层事件已经送达;必须先封存 attempt,后续绝不重试该入口。 # 超时也可能表示底层事件已经送达;必须先封存 attempt,后续绝不重试该入口。
self._entry_was_tapped = True self._entry_was_tapped = True
self._entry_bounds_attempted = bounds
self._entry_rpc_outcome = "ambiguous"
self._tap_bounds_once(bounds) self._tap_bounds_once(bounds)
self._entry_rpc_outcome = "completed"
def tap_sku_option(self, bounds: str) -> None: def tap_sku_option(self, bounds: str) -> None:
if bounds in self._option_bounds_attempted:
raise SkuSelectionDeviceAdapterError("同一规格选项已经尝试过,拒绝重试。")
# 规格 RPC 也可能送达后超时;按 exact bounds 封存本次唯一机会。
self._option_bounds_attempted.add(bounds)
self._option_rpc_outcomes[bounds] = "ambiguous"
self._tap_bounds_once(bounds) self._tap_bounds_once(bounds)
self._option_rpc_outcomes[bounds] = "completed"
def reveal_size_options_once(self) -> None:
if self._reveal_attempted:
raise SkuSelectionDeviceAdapterError("规格显示动作已经尝试过,拒绝重试。")
# 手势唯一机会在 RPC 前封存;生产 API 不接受方向、坐标或步数参数。
self._reveal_attempted = True
self._reveal_rpc_outcome = "ambiguous"
self._call(
"jsonrpc_call",
"swipe",
list(_REVEAL_GESTURE),
timeout=self._timeout_seconds,
)
self._reveal_rpc_outcome = "completed"
def leave_sku_panel(self) -> None: def leave_sku_panel(self) -> None:
if self._left_panel: if self._left_panel:
raise SkuSelectionDeviceAdapterError("规格面板已经执行过返回,已停止操作。") raise SkuSelectionDeviceAdapterError("规格面板已经执行过返回,已停止操作。")
# 底层调用即使报错也可能已把返回事件送达;先封存本次机会,finally 不得再次返回。 # 底层调用即使报错也可能已把返回事件送达;先封存本次机会,finally 不得再次返回。
self._left_panel = True self._left_panel = True
self._back_rpc_outcome = "ambiguous"
self._call("jsonrpc_call", "pressKey", ["back"], timeout=self._timeout_seconds) self._call("jsonrpc_call", "pressKey", ["back"], timeout=self._timeout_seconds)
self._back_rpc_outcome = "completed"
def capture_screenshot(self) -> str: def capture_screenshot(self) -> str:
value = self._call("jsonrpc_call", "takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds) value = self._call("jsonrpc_call", "takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds)
@@ -312,9 +382,11 @@ class SkuSelectionRunner:
stage = "safe_exit" stage = "safe_exit"
flow.exit_sku_panel_safely() flow.exit_sku_panel_safely()
_require_completed_action_audit(adapter)
stage = "publish" stage = "publish"
manifest_path.write_text( manifest_path.write_text(
json.dumps(_manifest(inspection, serial, link, screenshot_path, task_color, task_size), ensure_ascii=False, indent=2, sort_keys=True) + "\n", json.dumps(_manifest(inspection, serial, link, screenshot_path, task_color, task_size, adapter), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8", encoding="utf-8",
) )
# Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。 # Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。
@@ -340,11 +412,10 @@ class SkuSelectionRunner:
_annotate_mapped_failure(mapped, error, stage) _annotate_mapped_failure(mapped, error, stage)
raise mapped from error raise mapped from error
finally: finally:
# 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。 # 结果不明只允许读回 pending;失败路径绝不继续后续动作或自动 Back。
if flow is not None and adapter is not None and adapter.entry_was_tapped and not adapter.left_panel: if flow is not None:
try: try:
flow.reconcile_pending_action() flow.reconcile_pending_action()
flow.exit_sku_panel_safely()
except (SkuSelectionRunError, SkuSelectionError): except (SkuSelectionRunError, SkuSelectionError):
pass pass
@@ -411,9 +482,42 @@ def _require_screenshot_size(screenshot_path: Path) -> None:
raise SkuSelectionScreenshotError("原始截图无效,未发布任何证据产物。") from error raise SkuSelectionScreenshotError("原始截图无效,未发布任何证据产物。") from error
def _manifest(inspection: DeviceInspection, serial: str, link: ProductUrl, screenshot_path: Path, task_color: str, task_size: str) -> dict[str, Any]: def _require_completed_action_audit(adapter: UiautomatorSkuPanelAdapter) -> None:
"""发布只接受完整正常链;测试替身或缺动作结果不能伪造真机闭环。"""
if (
not adapter.entry_was_tapped
or adapter.entry_bounds_attempted != _ENTRY_TEXT_BOUNDS
or adapter.entry_rpc_outcome != "completed"
or adapter.option_rpc_outcomes
!= tuple(
sorted(
(
(_TARGET_COLOR_UNROLLED_BOUNDS, "completed"),
(_TARGET_SIZE_BOUNDS, "completed"),
)
)
)
or not adapter.reveal_attempted
or adapter.reveal_rpc_outcome != "completed"
or adapter.back_attempts != 1
or adapter.back_rpc_outcome != "completed"
):
raise SkuSelectionRunError("规格动作审计链不完整,未发布任何证据产物。")
def _manifest(
inspection: DeviceInspection,
serial: str,
link: ProductUrl,
screenshot_path: Path,
task_color: str,
task_size: str,
adapter: UiautomatorSkuPanelAdapter,
) -> dict[str, Any]:
"""仅写可审计摘要;原始 serial、节点树、页面文案和实际截图内容均不写入 manifest。""" """仅写可审计摘要;原始 serial、节点树、页面文案和实际截图内容均不写入 manifest。"""
option_outcomes = dict(adapter.option_rpc_outcomes)
return { return {
"schema_version": 1, "schema_version": 1,
"captured_at": datetime.now(UTC).isoformat(), "captured_at": datetime.now(UTC).isoformat(),
@@ -422,8 +526,36 @@ def _manifest(inspection: DeviceInspection, serial: str, link: ProductUrl, scree
"target_selection": {"color": task_color, "size": task_size}, "target_selection": {"color": task_color, "size": task_size},
"unit_price": EXPECTED_UNIT_PRICE, "unit_price": EXPECTED_UNIT_PRICE,
"selection_status": "restored", "selection_status": "restored",
"panel_status": "verified", "panel_status": "verified_before_back",
"safe_exit": "completed", "back_attempts": adapter.back_attempts,
"back_rpc_outcome": adapter.back_rpc_outcome,
"actions": {
"sku_entry": {
"attempts": int(adapter.entry_was_tapped),
"rpc_outcome": adapter.entry_rpc_outcome,
},
"target_color": {
"attempts": int(_TARGET_COLOR_UNROLLED_BOUNDS in option_outcomes),
"rpc_outcome": option_outcomes.get(
_TARGET_COLOR_UNROLLED_BOUNDS, "not_attempted"
),
},
"size_reveal": {
"attempts": int(adapter.reveal_attempted),
"rpc_outcome": adapter.reveal_rpc_outcome,
},
"target_size": {
"attempts": int(_TARGET_SIZE_BOUNDS in option_outcomes),
"rpc_outcome": option_outcomes.get(
_TARGET_SIZE_BOUNDS, "not_attempted"
),
},
"back": {
"attempts": adapter.back_attempts,
"rpc_outcome": adapter.back_rpc_outcome,
},
},
"post_exit_status": "human_review_required",
"page_identity": "human_review_required", "page_identity": "human_review_required",
"channel": "wifi" if ":" in serial else "usb", "channel": "wifi" if ":" in serial else "usb",
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(), "serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
+23 -13
View File
@@ -18,17 +18,19 @@ import cmbuyer_client.pdd.sku_reveal_spike as reveal_module
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
from cmbuyer_client.pdd.sku_reveal_spike import ( from cmbuyer_client.pdd.sku_reveal_spike import (
_annotate_reveal_failure, _annotate_reveal_failure,
_candidate_projection,
_RevealEvidenceAdapter,
safe_reveal_failure_stage, safe_reveal_failure_stage,
SkuRevealSpikeCapturer, SkuRevealSpikeCapturer,
SkuRevealSpikeError, SkuRevealSpikeError,
_require_safe_reveal_path,
) )
from cmbuyer_client.pdd.sku_selection import ( from cmbuyer_client.pdd.sku_selection import (
SkuSelectionError, SkuSelectionError,
_annotate_sku_entry_failure, _annotate_sku_entry_failure,
_parse_nodes, _parse_nodes,
_require_safe_reveal_path,
_revealed_unselected_projection as _candidate_projection,
)
from cmbuyer_client.pdd.sku_selection_runner import (
UiautomatorSkuPanelAdapter as _RevealEvidenceAdapter,
) )
@@ -350,7 +352,7 @@ def _candidate_cross_variant_residue(variant: str, kind: str) -> str:
def _candidate_with_color_unselected() -> str: def _candidate_with_color_unselected() -> str:
root = ElementTree.fromstring(_candidate_unselected()) root = ElementTree.fromstring(_candidate_current_only())
target = next( target = next(
node node
for node in root.iter("node") for node in root.iter("node")
@@ -364,7 +366,7 @@ def _candidate_with_color_unselected() -> str:
def _candidate_with_submit_risk(kind: str) -> str: def _candidate_with_submit_risk(kind: str) -> str:
root = ElementTree.fromstring(_candidate_unselected()) root = ElementTree.fromstring(_candidate_current_only())
parents = {child: parent for parent in root.iter() for child in parent} parents = {child: parent for parent in root.iter() for child in parent}
submit = next( submit = next(
node node
@@ -379,6 +381,13 @@ def _candidate_with_submit_risk(kind: str) -> str:
parent.append(ElementTree.fromstring(ElementTree.tostring(submit, encoding="unicode"))) parent.append(ElementTree.fromstring(ElementTree.tostring(submit, encoding="unicode")))
elif kind == "moved_up": elif kind == "moved_up":
submit.set("bounds", "[285,1900][795,1959]") submit.set("bounds", "[285,1900][795,1959]")
elif kind in {"extra_altered", "extra_moved_up"}:
extra = ElementTree.fromstring(ElementTree.tostring(submit, encoding="unicode"))
if kind == "extra_altered":
extra.set("text", "异常提交订单文案")
else:
extra.set("bounds", "[285,1900][795,1959]")
parent.append(extra)
else: else:
raise AssertionError(kind) raise AssertionError(kind)
return ElementTree.tostring(root, encoding="unicode") return ElementTree.tostring(root, encoding="unicode")
@@ -416,9 +425,9 @@ def _png() -> str:
class _FakeDevice: class _FakeDevice:
def __init__(self) -> None: def __init__(self) -> None:
self.hierarchy = "<hierarchy />" self.hierarchy = "<hierarchy />"
self.empty_hierarchy = _EMPTY self.empty_hierarchy = _CURRENT_ONLY_EMPTY
self.color_hierarchy = _COLOR_ONLY self.color_hierarchy = _CURRENT_ONLY_COLOR
self.after_hierarchy = _candidate_unselected() self.after_hierarchy = _candidate_current_only()
self.calls: list[tuple[object, ...]] = [] self.calls: list[tuple[object, ...]] = []
self.swipe_error = False self.swipe_error = False
@@ -545,8 +554,9 @@ class SkuRevealSpikeTests(unittest.TestCase):
self.assertTrue((target / "after" / "hierarchy.xml").is_file()) self.assertTrue((target / "after" / "hierarchy.xml").is_file())
def test_candidate_price_variants_reject_hybrid_duplicate_unknown_and_parent_drift(self) -> None: def test_candidate_price_variants_reject_hybrid_duplicate_unknown_and_parent_drift(self) -> None:
# 两个真实嵌套 variant 各自唯一命中;任意混搭、额外子节点或父子关系漂移均拒绝。 # 生产只放行 current-only;历史 dual 及任意混搭、额外节点、父链漂移均拒绝。
_candidate_projection(_parse_nodes(_candidate_unselected())) with self.assertRaises(SkuSelectionError):
_candidate_projection(_parse_nodes(_candidate_unselected()))
_candidate_projection(_parse_nodes(_candidate_current_only())) _candidate_projection(_parse_nodes(_candidate_current_only()))
for variant in ("dual", "current_only"): for variant in ("dual", "current_only"):
kinds = ["hybrid", "duplicate", "unknown", "unknown_child", "parent_drift"] kinds = ["hybrid", "duplicate", "unknown", "unknown_child", "parent_drift"]
@@ -683,7 +693,7 @@ class SkuRevealSpikeTests(unittest.TestCase):
self._assert_after_rejected_once(_candidate_with_color_unselected()) self._assert_after_rejected_once(_candidate_with_color_unselected())
def test_submit_hard_reject_zone_risk_is_not_published_or_retried(self) -> None: def test_submit_hard_reject_zone_risk_is_not_published_or_retried(self) -> None:
for kind in ("missing", "duplicate", "moved_up"): for kind in ("missing", "duplicate", "moved_up", "extra_altered", "extra_moved_up"):
with self.subTest(kind=kind): with self.subTest(kind=kind):
self._assert_after_rejected_once(_candidate_with_submit_risk(kind)) self._assert_after_rejected_once(_candidate_with_submit_risk(kind))
@@ -739,7 +749,7 @@ class SkuRevealSpikeTests(unittest.TestCase):
device.jsonrpc_call = drift # type: ignore[method-assign] device.jsonrpc_call = drift # type: ignore[method-assign]
with TemporaryDirectory() as directory: with TemporaryDirectory() as directory:
target = Path(directory) / "evidence" target = Path(directory) / "evidence"
with self.assertRaises(SkuRevealSpikeError) as raised: with self.assertRaises((SkuRevealSpikeError, SkuSelectionError)) as raised:
self._capturer(device).capture( self._capturer(device).capture(
"192.168.0.173:5555", "192.168.0.173:5555",
"937122477375", "937122477375",
@@ -850,7 +860,7 @@ class SkuRevealSpikeTests(unittest.TestCase):
"not-a-bound", "not-a-bound",
), ),
): ):
with self.subTest(), self.assertRaises(SkuRevealSpikeError): with self.subTest(), self.assertRaises((SkuRevealSpikeError, SkuSelectionError)):
_require_safe_reveal_path(_parse_nodes(hierarchy)) _require_safe_reveal_path(_parse_nodes(hierarchy))
+357 -30
View File
@@ -3,8 +3,10 @@ from __future__ import annotations
import ast import ast
import base64 import base64
from contextlib import redirect_stderr from contextlib import redirect_stderr
from functools import lru_cache
from io import BytesIO from io import BytesIO
import importlib.util import importlib.util
import json
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
import unittest import unittest
@@ -49,6 +51,14 @@ _TASK_SIZE = "M(建议100-115)"
_PRODUCT_PAGE = _ENTRY_FIXTURE.read_text(encoding="utf-8") _PRODUCT_PAGE = _ENTRY_FIXTURE.read_text(encoding="utf-8")
def _revealed_current_only() -> str:
# 与 spike 测试共用由已验 M fixture 机械恢复出的 current-only 未选态;生产判据只在源码一处。
from tests.pdd.test_sku_reveal_spike import _candidate_current_only
return _candidate_current_only()
@lru_cache(maxsize=1)
def _png_base64() -> str: def _png_base64() -> str:
image = Image.new("RGB", (1080, 2376), "white") image = Image.new("RGB", (1080, 2376), "white")
raw = BytesIO() raw = BytesIO()
@@ -59,8 +69,9 @@ def _png_base64() -> str:
class _RawDevice: class _RawDevice:
def __init__(self, hierarchy: str = _PRODUCT_PAGE, screenshot: str | None = None) -> None: def __init__(self, hierarchy: str = _PRODUCT_PAGE, screenshot: str | None = None) -> None:
self.hierarchy = hierarchy self.hierarchy = hierarchy
self.panel_hierarchy = _EMPTY_FIXTURE.read_text(encoding="utf-8") self.panel_hierarchy = _CURRENT_ONLY_EMPTY_FIXTURE.read_text(encoding="utf-8")
self.color_hierarchy = _COLOR_FIXTURE.read_text(encoding="utf-8") self.color_hierarchy = _CURRENT_ONLY_COLOR_FIXTURE.read_text(encoding="utf-8")
self.revealed_hierarchy = _revealed_current_only()
self.version = "8.17.0" self.version = "8.17.0"
self.package = "com.xunmeng.pinduoduo" self.package = "com.xunmeng.pinduoduo"
self.screenshot = _png_base64() if screenshot is None else screenshot self.screenshot = _png_base64() if screenshot is None else screenshot
@@ -89,6 +100,11 @@ class _RawDevice:
raise AssertionError(params) raise AssertionError(params)
self._apply_tap(int(params[0]), int(params[1])) self._apply_tap(int(params[0]), int(params[1]))
return "" return ""
if method == "swipe":
if params != [360, 1900, 360, 1300, 30]:
raise AssertionError(params)
self.hierarchy = self.revealed_hierarchy
return ""
raise AssertionError(method) raise AssertionError(method)
def _apply_tap(self, x: int, y: int) -> None: def _apply_tap(self, x: int, y: int) -> None:
@@ -405,25 +421,223 @@ class SkuSelectionFlowTests(unittest.TestCase):
flow.open_sku_panel(_TARGET_URL) flow.open_sku_panel(_TARGET_URL)
self.assertEqual(_actions(device, "click"), []) self.assertEqual(_actions(device, "click"), [])
def test_target_mapping_stops_at_unproven_reveal_then_s_to_m_is_exact(self) -> None: def test_current_only_exact_transition_reveals_then_selects_m_once(self) -> None:
device = _RawDevice() device = _RawDevice()
flow = _flow(device) flow = _flow(device)
flow.open_sku_panel(_TARGET_URL) flow.open_sku_panel(_TARGET_URL)
with self.assertRaisesRegex(SkuSelectionError, "受控显示动作尚未取证"): flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE)) self.assertEqual(flow.read_sku_unit_price(), "12.88")
self.assertEqual(_tap_centers(device), [(865, 2218), (528, 1387)]) self.assertEqual(_tap_centers(device), [(865, 2218), (528, 1387), (635, 1624)])
self.assertEqual(len(_actions(device, "swipe")), 1)
self.assertEqual(_actions(device, "pressKey"), []) self.assertEqual(_actions(device, "pressKey"), [])
restored = _RawDevice(_S_FIXTURE.read_text(encoding="utf-8")) restored = _RawDevice(_S_FIXTURE.read_text(encoding="utf-8"))
restored_flow = _flow(restored) restored_flow = _flow(restored)
restored_flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE)) restored_flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(restored_flow.read_sku_unit_price(), "12.88") 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(_tap_centers(restored), [(635, 1624)])
self.assertEqual(_actions(restored, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)]) self.assertEqual(_actions(restored, "swipe"), [])
def test_dual_color_only_is_zero_action_before_reveal(self) -> None:
for fixture in (_COLOR_FIXTURE, _CURRENT_ONLY_COLOR_FIXTURE):
with self.subTest(fixture=fixture.name):
device = _RawDevice(fixture.read_text(encoding="utf-8"))
with self.assertRaises(SkuSelectionError):
_flow(device).select_sku_options(
resolve_task_selection(_TASK_COLOR, _TASK_SIZE)
)
self.assertEqual(_actions(device, "click"), [])
self.assertEqual(_actions(device, "swipe"), [])
def test_revealed_current_only_is_a_unique_profile_but_not_a_reentry_shortcut(self) -> None:
hierarchy = _revealed_current_only()
spec = _classify_panel(_parse_nodes(hierarchy))
self.assertIs(spec.profile, _PanelProfile.SIZE_VISIBLE_UNSELECTED)
self.assertEqual(spec.price_layout.__class__.__name__, "_CurrentOnlyPriceLayout")
device = _RawDevice(hierarchy)
with self.assertRaises(SkuSelectionError):
_flow(device).select_sku_options(
resolve_task_selection(_TASK_COLOR, _TASK_SIZE)
)
self.assertEqual(_actions(device, "click"), [])
self.assertEqual(_actions(device, "swipe"), [])
def test_revealed_exact_m_contract_rejects_missing_duplicate_disabled_near_and_other_selected(self) -> None:
base = _revealed_current_only()
for kind in ("missing", "duplicate", "disabled", "near", "other_selected"):
root = ElementTree.fromstring(base)
parents = {child: parent for parent in root.iter() for child in parent}
m_action = next(
node for node in root.iter("node")
if node.get("text") == "M(建议100-115)"
)
wrapper = parents[m_action]
if kind == "missing":
parents[wrapper].remove(wrapper)
elif kind == "duplicate":
parents[wrapper].append(
ElementTree.fromstring(ElementTree.tostring(wrapper, encoding="unicode"))
)
elif kind == "disabled":
m_action.set("enabled", "false")
elif kind == "near":
m_action.set("text", "M(建议100-115)相近")
else:
s_action = next(
node for node in root.iter("node")
if node.get("text") == "S(建议80-100)"
)
s_action.set("selected", "true")
with self.subTest(kind=kind), self.assertRaises(SkuSelectionError):
_classify_panel(
_parse_nodes(ElementTree.tostring(root, encoding="unicode"))
)
def test_reveal_requires_two_consecutive_stable_frames_and_terminal_blocks_reentry(self) -> None:
class UnstableRevealDevice(_RawDevice):
def __init__(self) -> None:
super().__init__(_CURRENT_ONLY_EMPTY_FIXTURE.read_text(encoding="utf-8"))
self.after_reveal_reads = 0
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "dumpWindowHierarchy" and _actions(self, "swipe"):
self.after_reveal_reads += 1
candidate = _revealed_current_only()
self.hierarchy = (
candidate
if self.after_reveal_reads % 2
else candidate.replace("请选择: 尺码", "未知摘要")
)
return super().jsonrpc_call(method, params, timeout)
device = UnstableRevealDevice()
flow = _flow(device)
with self.assertRaises(SkuSelectionError):
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
action_count = len(_actions(device, "click")) + len(_actions(device, "swipe"))
self.assertEqual(len(_actions(device, "swipe")), 1)
self.assertEqual(_tap_centers(device), [(528, 1387)])
with self.assertRaisesRegex(SkuSelectionError, "不可重入终止态"):
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(
len(_actions(device, "click")) + len(_actions(device, "swipe")),
action_count,
)
def test_reveal_timeout_is_read_only_reconciled_without_m_back_or_retry(self) -> None:
class RevealTimeoutDevice(_RawDevice):
def __init__(self, delivered: bool) -> None:
super().__init__(_CURRENT_ONLY_EMPTY_FIXTURE.read_text(encoding="utf-8"))
self.delivered = delivered
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "swipe":
if self.delivered:
super().jsonrpc_call(method, params, timeout)
else:
self.calls.append(("jsonrpc", method, params, timeout))
raise TimeoutError("private reveal timeout")
return super().jsonrpc_call(method, params, timeout)
for delivered in (False, True):
with self.subTest(delivered=delivered):
device = RevealTimeoutDevice(delivered)
flow = _flow(device)
with self.assertRaises(SkuSelectionRunError):
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
try:
flow.reconcile_pending_action()
except SkuSelectionError:
pass
self.assertEqual(len(_actions(device, "swipe")), 1)
self.assertEqual(_tap_centers(device), [(528, 1387)])
self.assertEqual(_actions(device, "pressKey"), [])
with self.assertRaisesRegex(SkuSelectionError, "不可重入终止态"):
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(len(_actions(device, "swipe")), 1)
def test_m_timeout_is_read_only_reconciled_without_back_or_retry(self) -> None:
class MTimeoutDevice(_RawDevice):
def __init__(self, delivered: bool) -> None:
super().__init__(_CURRENT_ONLY_EMPTY_FIXTURE.read_text(encoding="utf-8"))
self.delivered = delivered
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "click" and params == [635, 1624]:
if self.delivered:
super().jsonrpc_call(method, params, timeout)
else:
self.calls.append(("jsonrpc", method, params, timeout))
raise TimeoutError("private M timeout")
return super().jsonrpc_call(method, params, timeout)
for delivered in (False, True):
with self.subTest(delivered=delivered):
device = MTimeoutDevice(delivered)
flow = _flow(device)
with self.assertRaises(SkuSelectionRunError):
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
try:
flow.reconcile_pending_action()
except SkuSelectionError:
pass
self.assertEqual(len(_actions(device, "swipe")), 1)
self.assertEqual(_tap_centers(device), [(528, 1387), (635, 1624)])
self.assertEqual(_actions(device, "pressKey"), [])
with self.assertRaisesRegex(SkuSelectionError, "不可重入终止态"):
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(_tap_centers(device), [(528, 1387), (635, 1624)])
def test_foreground_is_freshly_rechecked_before_s_reveal_and_m_mutations(self) -> None:
class ForegroundDriftDevice(_RawDevice):
def __init__(self, hierarchy: str, flip_at: int) -> None:
super().__init__(hierarchy)
self.flip_at = flip_at
self.current_reads = 0
def app_current(self) -> dict[str, str]:
current = super().app_current()
self.current_reads += 1
if self.current_reads == self.flip_at:
return {"package": "other"}
return current
cases = (
(
"s",
_S_FIXTURE.read_text(encoding="utf-8"),
2,
[],
0,
),
(
"reveal",
_CURRENT_ONLY_EMPTY_FIXTURE.read_text(encoding="utf-8"),
4,
[(528, 1387)],
0,
),
(
"m",
_CURRENT_ONLY_EMPTY_FIXTURE.read_text(encoding="utf-8"),
7,
[(528, 1387)],
1,
),
)
for name, hierarchy, flip_at, expected_taps, expected_swipes in cases:
with self.subTest(name=name):
device = ForegroundDriftDevice(hierarchy, flip_at)
with self.assertRaises(SkuSelectionError):
_flow(device).select_sku_options(
resolve_task_selection(_TASK_COLOR, _TASK_SIZE)
)
self.assertEqual(_tap_centers(device), expected_taps)
self.assertEqual(len(_actions(device, "swipe")), expected_swipes)
self.assertEqual(_actions(device, "pressKey"), [])
def test_current_only_empty_to_color_only_uses_the_matching_exact_spec(self) -> None: def test_current_only_empty_to_color_only_uses_the_matching_exact_spec(self) -> None:
device = _RawDevice() device = _RawDevice()
@@ -432,15 +646,15 @@ class SkuSelectionFlowTests(unittest.TestCase):
flow = _flow(device) flow = _flow(device)
flow.open_sku_panel(_TARGET_URL) flow.open_sku_panel(_TARGET_URL)
with self.assertRaisesRegex(SkuSelectionError, "受控显示动作尚未取证"): flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(_tap_centers(device), [(865, 2218), (528, 1387)]) self.assertEqual(_tap_centers(device), [(865, 2218), (528, 1387), (635, 1624)])
self.assertEqual(len(_actions(device, "swipe")), 1)
matched = _classify_panel(_parse_nodes(device.hierarchy)) matched = _classify_panel(_parse_nodes(device.hierarchy))
self.assertIs(matched.profile, _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN) self.assertIs(matched.profile, _PanelProfile.TARGETS_SELECTED)
self.assertEqual( self.assertEqual(
matched.price_layout.__class__.__name__, matched.price_layout.__class__.__name__,
"_CurrentOnlyPriceLayout", "_DualPriceLayout",
) )
def test_dual_and_current_only_variants_each_match_one_complete_spec(self) -> None: def test_dual_and_current_only_variants_each_match_one_complete_spec(self) -> None:
@@ -585,7 +799,9 @@ class SkuSelectionFlowTests(unittest.TestCase):
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE)) flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertNotIn("受控显示动作尚未取证", str(raised.exception)) self.assertNotIn("受控显示动作尚未取证", str(raised.exception))
self.assertEqual(_tap_centers(device), [(865, 2218), (528, 1387)]) expected = [(865, 2218), (528, 1387)] if empty_fixture is _CURRENT_ONLY_EMPTY_FIXTURE else [(865, 2218)]
self.assertEqual(_tap_centers(device), expected)
self.assertEqual(_actions(device, "swipe"), [])
self.assertEqual(_actions(device, "pressKey"), []) self.assertEqual(_actions(device, "pressKey"), [])
def test_full_verified_entry_structure_taps_exact_text_child_once(self) -> None: def test_full_verified_entry_structure_taps_exact_text_child_once(self) -> None:
@@ -851,10 +1067,8 @@ class SkuSelectionFlowTests(unittest.TestCase):
flow.open_sku_panel(_TARGET_URL) flow.open_sku_panel(_TARGET_URL)
device.select_alternates() device.select_alternates()
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE)) flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual( self.assertEqual(_tap_centers(device), [(865, 2218), (635, 1624)])
_tap_centers(device), self.assertEqual(_actions(device, "swipe"), [])
[(865, 2218), (635, 1624)],
)
def test_price_rejects_coupon_prefix_extra_amount_and_bottom_action(self) -> None: def test_price_rejects_coupon_prefix_extra_amount_and_bottom_action(self) -> None:
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"): 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"):
@@ -876,7 +1090,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
_flow(_RawDevice(clickable_parent)).read_sku_unit_price() _flow(_RawDevice(clickable_parent)).read_sku_unit_price()
def test_public_api_and_protocol_have_no_broad_or_order_operations(self) -> None: def test_public_api_and_protocol_have_no_broad_or_order_operations(self) -> None:
forbidden = {"quantity", "confirm", "authorization", "fence", "submit", "payment", "click"} forbidden = {"quantity", "confirm", "authorization", "fence", "submit", "payment", "click", "swipe", "scroll"}
self.assertTrue(forbidden.isdisjoint(SkuSelectionFlow.__dict__)) self.assertTrue(forbidden.isdisjoint(SkuSelectionFlow.__dict__))
self.assertTrue(forbidden.isdisjoint(SkuPanelDevice.__dict__)) self.assertTrue(forbidden.isdisjoint(SkuPanelDevice.__dict__))
self.assertTrue(forbidden.isdisjoint(pdd.__all__)) self.assertTrue(forbidden.isdisjoint(pdd.__all__))
@@ -900,6 +1114,8 @@ class SkuSelectionFlowTests(unittest.TestCase):
runner_tree = ast.parse(files[1].read_text(encoding="utf-8")) 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"] 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) self.assertEqual(len(click_calls), 1)
swipe_calls = [node for node in ast.walk(runner_tree) if isinstance(node, ast.Constant) and node.value == "swipe"]
self.assertEqual(len(swipe_calls), 1)
def test_entry_wait_rejects_unchanged_or_duplicate_page_without_click(self) -> None: def test_entry_wait_rejects_unchanged_or_duplicate_page_without_click(self) -> None:
now = [0.0] now = [0.0]
@@ -947,22 +1163,24 @@ class SkuSelectionFlowTests(unittest.TestCase):
class _CompletedFlow: class _CompletedFlow:
"""仅隔离 runner 文件发布测试;生产 Flow 在 reveal 取证前仍必须停止。""" """仅隔离 runner 文件发布测试,同时必须形成完整动作审计链。"""
def __init__(self, device: object, *args: object, **kwargs: object) -> None: def __init__(self, device: object, *args: object, **kwargs: object) -> None:
self.device = device self.device = device
def open_sku_panel(self, product_url: str, pre_intent_hierarchy: str | None = None) -> None: def open_sku_panel(self, product_url: str, pre_intent_hierarchy: str | None = None) -> None:
return None self.device.tap_sku_entry("[688,2184][1042,2253]")
def select_sku_options(self, selection: object) -> None: def select_sku_options(self, selection: object) -> None:
return None self.device.tap_sku_option("[372,1188][684,1587]")
self.device.reveal_size_options_once()
self.device.tap_sku_option("[439,1582][831,1667]")
def verify_target_selection_and_read_price(self, selection: object) -> str: def verify_target_selection_and_read_price(self, selection: object) -> str:
return "12.88" return "12.88"
def exit_sku_panel_safely(self) -> None: def exit_sku_panel_safely(self) -> None:
return None self.device.leave_sku_panel()
def reconcile_pending_action(self) -> None: def reconcile_pending_action(self) -> None:
return None return None
@@ -989,15 +1207,55 @@ class SkuSelectionRunnerTests(unittest.TestCase):
self.assertEqual(result.unit_price, "12.88") self.assertEqual(result.unit_price, "12.88")
manifest = result.manifest_path.read_text(encoding="utf-8") manifest = result.manifest_path.read_text(encoding="utf-8")
manifest_data = json.loads(manifest)
self.assertTrue(result.screenshot_path.is_file()) self.assertTrue(result.screenshot_path.is_file())
self.assertNotIn("device-1", manifest) self.assertNotIn("device-1", manifest)
self.assertNotIn("hierarchy", manifest) self.assertNotIn("hierarchy", manifest)
self.assertNotIn("已选", manifest) self.assertNotIn("已选", manifest)
self.assertIn('"unit_price": "12.88"', manifest) self.assertIn('"unit_price": "12.88"', manifest)
self.assertIn('"selection_status": "restored"', manifest) self.assertIn('"selection_status": "restored"', manifest)
self.assertIn('"panel_status": "verified"', manifest) self.assertIn('"panel_status": "verified_before_back"', manifest)
self.assertIn('"safe_exit": "completed"', manifest) self.assertIn('"back_attempts": 1', manifest)
self.assertIn('"back_rpc_outcome": "completed"', manifest)
self.assertIn('"post_exit_status": "human_review_required"', manifest)
self.assertNotIn('"safe_exit"', manifest)
self.assertEqual(
manifest_data["actions"],
{
"sku_entry": {"attempts": 1, "rpc_outcome": "completed"},
"target_color": {"attempts": 1, "rpc_outcome": "completed"},
"size_reveal": {"attempts": 1, "rpc_outcome": "completed"},
"target_size": {"attempts": 1, "rpc_outcome": "completed"},
"back": {"attempts": 1, "rpc_outcome": "completed"},
},
)
self.assertFalse((target / "hierarchy.xml").exists()) self.assertFalse((target / "hierarchy.xml").exists())
self.assertEqual(len(_actions(device, "pressKey")), 1)
def test_publish_rejects_flow_stub_without_exact_action_audit_chain(self) -> None:
class IncompleteFlow(_CompletedFlow):
def open_sku_panel(self, product_url: str, pre_intent_hierarchy: str | None = None) -> None:
return None
def select_sku_options(self, selection: object) -> None:
return None
def exit_sku_panel_safely(self) -> None:
return None
device = _RawDevice()
with TemporaryDirectory() as temporary:
target = Path(temporary) / "result"
with (
patch.object(runner_module, "SkuSelectionFlow", IncompleteFlow),
self.assertRaises(SkuSelectionRunError) as raised,
):
self._runner(_FakeAdb(), device).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target
)
self.assertEqual(safe_failure_stage(raised.exception), "safe_exit")
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
self.assertEqual(_actions(device, "pressKey"), []) self.assertEqual(_actions(device, "pressKey"), [])
def test_failure_stage_is_fixed_control_flow_metadata_without_error_text(self) -> None: def test_failure_stage_is_fixed_control_flow_metadata_without_error_text(self) -> None:
@@ -1223,6 +1481,34 @@ class SkuSelectionRunnerTests(unittest.TestCase):
self.assertTrue(adapter.entry_was_tapped) self.assertTrue(adapter.entry_was_tapped)
self.assertEqual(_actions(adapter._device, "click"), [("jsonrpc", "click", [865, 2218], 10)]) self.assertEqual(_actions(adapter._device, "click"), [("jsonrpc", "click", [865, 2218], 10)])
def test_adapter_seals_every_named_mutation_before_rpc(self) -> None:
device = _RawDevice(_PRODUCT_PAGE)
adapter = UiautomatorSkuPanelAdapter(device, 10)
adapter.tap_sku_entry("[688,2184][1042,2253]")
with self.assertRaises(SkuSelectionDeviceAdapterError):
adapter.tap_sku_entry("[688,2184][1042,2253]")
adapter.tap_sku_option("[372,1188][684,1587]")
with self.assertRaises(SkuSelectionDeviceAdapterError):
adapter.tap_sku_option("[372,1188][684,1587]")
adapter.reveal_size_options_once()
with self.assertRaises(SkuSelectionDeviceAdapterError):
adapter.reveal_size_options_once()
adapter.leave_sku_panel()
with self.assertRaises(SkuSelectionDeviceAdapterError):
adapter.leave_sku_panel()
self.assertEqual(len(_actions(device, "click")), 2)
self.assertEqual(len(_actions(device, "swipe")), 1)
self.assertEqual(len(_actions(device, "pressKey")), 1)
self.assertEqual(adapter.option_attempts, 1)
self.assertEqual(adapter.entry_rpc_outcome, "completed")
self.assertEqual(adapter.reveal_rpc_outcome, "completed")
self.assertEqual(adapter.back_attempts, 1)
self.assertEqual(adapter.back_rpc_outcome, "completed")
def test_entry_stability_interruptions_never_click(self) -> None: def test_entry_stability_interruptions_never_click(self) -> None:
now = [0.0] now = [0.0]
class SequenceDevice(_RawDevice): class SequenceDevice(_RawDevice):
@@ -1276,8 +1562,48 @@ class SkuSelectionRunnerTests(unittest.TestCase):
with self.assertRaises(SkuSelectionError): _flow(device).exit_sku_panel_safely() with self.assertRaises(SkuSelectionError): _flow(device).exit_sku_panel_safely()
self.assertEqual(_actions(device, "pressKey"), []) self.assertEqual(_actions(device, "pressKey"), [])
def test_unproven_reveal_publishes_nothing_and_safely_exits_once(self) -> None: def test_back_precondition_rejects_every_non_target_profile_after_screenshot_reverify(self) -> None:
drifts = (
_CURRENT_ONLY_EMPTY_FIXTURE.read_text(encoding="utf-8"),
_CURRENT_ONLY_COLOR_FIXTURE.read_text(encoding="utf-8"),
_S_FIXTURE.read_text(encoding="utf-8"),
_revealed_current_only(),
)
for drift in drifts:
class DriftBeforeBackDevice(_RawDevice):
def __init__(self) -> None:
super().__init__()
self.after_screenshot_reads = 0
self.screenshot_seen = False
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "takeScreenshot":
value = super().jsonrpc_call(method, params, timeout)
self.screenshot_seen = True
return value
if method == "dumpWindowHierarchy" and self.screenshot_seen:
self.after_screenshot_reads += 1
if self.after_screenshot_reads == 2:
self.hierarchy = drift
return super().jsonrpc_call(method, params, timeout)
with self.subTest(profile=_classify_panel(_parse_nodes(drift)).profile), TemporaryDirectory() as temporary:
device = DriftBeforeBackDevice()
target = Path(temporary) / "result"
with self.assertRaises(SkuSelectionError) as raised:
self._runner(_FakeAdb(), device).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target
)
self.assertEqual(safe_failure_stage(raised.exception), "safe_exit")
self.assertEqual(_actions(device, "pressKey"), [])
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
def test_dual_precondition_publishes_nothing_without_reveal_or_back(self) -> None:
device = _RawDevice() device = _RawDevice()
device.panel_hierarchy = _EMPTY_FIXTURE.read_text(encoding="utf-8")
device.color_hierarchy = _COLOR_FIXTURE.read_text(encoding="utf-8")
with TemporaryDirectory() as temporary: with TemporaryDirectory() as temporary:
target = Path(temporary) / "out" target = Path(temporary) / "out"
with self.assertRaises(SkuSelectionError): with self.assertRaises(SkuSelectionError):
@@ -1285,7 +1611,8 @@ class SkuSelectionRunnerTests(unittest.TestCase):
self.assertFalse(target.exists()) self.assertFalse(target.exists())
self.assertFalse((target / "manifest.json").exists()) self.assertFalse((target / "manifest.json").exists())
self.assertEqual(list(Path(temporary).glob(".out.staging-*")), []) self.assertEqual(list(Path(temporary).glob(".out.staging-*")), [])
self.assertEqual(len(_actions(device, "pressKey")), 1) self.assertEqual(_actions(device, "swipe"), [])
self.assertEqual(len(_actions(device, "pressKey")), 0)
def test_option_timeout_reconciliation_controls_back_once(self) -> None: def test_option_timeout_reconciliation_controls_back_once(self) -> None:
class OptionTimeoutDevice(_RawDevice): class OptionTimeoutDevice(_RawDevice):
@@ -1300,7 +1627,7 @@ class SkuSelectionRunnerTests(unittest.TestCase):
raise TimeoutError("uncertain option") raise TimeoutError("uncertain option")
return super().jsonrpc_call(method, params, timeout) return super().jsonrpc_call(method, params, timeout)
for delivered, expected_back in ((False, 0), (True, 1)): for delivered, expected_back in ((False, 0), (True, 0)):
with self.subTest(delivered=delivered), TemporaryDirectory() as temporary: with self.subTest(delivered=delivered), TemporaryDirectory() as temporary:
device = OptionTimeoutDevice(delivered) device = OptionTimeoutDevice(delivered)
adb = _FakeAdb(); device.hierarchy = "<hierarchy />" adb = _FakeAdb(); device.hierarchy = "<hierarchy />"
@@ -1336,7 +1663,7 @@ class SkuSelectionRunnerTests(unittest.TestCase):
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionRunError): with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionRunError):
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result") self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
self.assertEqual(len(_actions(device, "click")), 1) self.assertEqual(len(_actions(device, "click")), 1)
self.assertEqual(len(_actions(device, "pressKey")), 1) self.assertEqual(len(_actions(device, "pressKey")), 0)
class SkuSelectionCliTests(unittest.TestCase): class SkuSelectionCliTests(unittest.TestCase):