diff --git a/client/src/cmbuyer_client/pdd/sku_reveal_spike.py b/client/src/cmbuyer_client/pdd/sku_reveal_spike.py index d3c1d60..7af793c 100644 --- a/client/src/cmbuyer_client/pdd/sku_reveal_spike.py +++ b/client/src/cmbuyer_client/pdd/sku_reveal_spike.py @@ -1,4 +1,4 @@ -"""T-103 尺码显示动作的一次性真机取证;不属于生产采购 Flow。""" +"""T-103 尺码显示动作的一次性真机证据采集。""" 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 .sku_selection import ( 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, _require_color_only_panel, - _require_color_action_chain, - _require_color_subtree, - _require_exact_selected_set, - _require_panel_chain, - _require_size_wrapper, - _require_size_action_chain, - _spec_for, + _require_safe_reveal_path, + _revealed_unselected_projection, resolve_task_selection, SkuSelectionError, SkuSelectionFlow, @@ -63,9 +41,6 @@ from .sku_selection_runner import ( _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_precondition", @@ -114,30 +89,6 @@ class SkuRevealSpikeResult: 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: """打开已取证面板、选一次目标颜色,再采集唯一 reveal 的前后证据。""" @@ -165,7 +116,7 @@ class SkuRevealSpikeCapturer: ) -> SkuRevealSpikeResult: stage = "reveal_precondition" staging: Path | None = None - adapter: _RevealEvidenceAdapter | None = None + adapter: UiautomatorSkuPanelAdapter | None = None try: if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID: raise SkuRevealSpikeError("商品不是 T-103 已取证目标,已停止取证。") @@ -176,7 +127,7 @@ class SkuRevealSpikeCapturer: deadline = self._clock() + self._timeout_seconds inspection = self._adb_client.inspect(serial) _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)) if adapter.display_size() != EXPECTED_SCREEN_SIZE: raise SkuRevealSpikeError("设备不是已取证的竖屏坐标空间,已停止取证。") @@ -193,13 +144,10 @@ class SkuRevealSpikeCapturer: sleep_function=self._sleep, ) flow.open_sku_panel(link.canonical_url, pre_intent) - try: - flow.select_sku_options(resolve_task_selection("黑色CHA(纯棉)", "M(建议100-115)")) - except SkuSelectionError as error: - if error.args != (_REVEAL_NOT_PROVEN,): - raise - else: - raise SkuRevealSpikeError("规格流程未停在已取证的仅颜色状态。") + # spike 只复用生产 Flow 的私有前置准备,不调用完整选择流程,避免重复 reveal/M。 + flow._prepare_reveal_precondition( + resolve_task_selection("黑色CHA(纯棉)", "M(建议100-115)") + ) before_hierarchy = adapter.dump_window_hierarchy() before_nodes = _parse_nodes(before_hierarchy) @@ -231,7 +179,7 @@ class SkuRevealSpikeCapturer: after_directory = staging / "after" _capture_frame(adapter, after_directory, after_hierarchy) reverified = adapter.dump_window_hierarchy() - if _candidate_projection(_parse_nodes(reverified)) != projection: + if _revealed_unselected_projection(_parse_nodes(reverified)) != projection: raise SkuRevealSpikeError("截图后候选状态漂移,未发布证据。") (after_directory / "hierarchy.xml").write_text(reverified, encoding="utf-8") @@ -265,7 +213,7 @@ class SkuRevealSpikeCapturer: def _wait_for_candidate( self, - adapter: _RevealEvidenceAdapter, + adapter: UiautomatorSkuPanelAdapter, deadline: float, ) -> tuple[tuple[tuple[str, ...], ...], str]: stable: tuple[tuple[str, ...], ...] | None = None @@ -276,7 +224,7 @@ class SkuRevealSpikeCapturer: raise SkuRevealSpikeError("reveal 后拼多多不在前台,未发布证据。") hierarchy = adapter.dump_window_hierarchy() try: - projection = _candidate_projection(_parse_nodes(hierarchy)) + projection = _revealed_unselected_projection(_parse_nodes(hierarchy)) except SkuSelectionError: projection = None if projection is not None and projection == stable: @@ -288,315 +236,7 @@ class SkuRevealSpikeCapturer: self._sleep(min(0.2, remaining)) -def _require_safe_reveal_path(nodes: list[Any]) -> 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: +def _capture_frame(adapter: UiautomatorSkuPanelAdapter, directory: Path, hierarchy: str) -> None: directory.mkdir() hierarchy_path = directory / "hierarchy.xml" hierarchy_path.write_text(hierarchy, encoding="utf-8") diff --git a/client/src/cmbuyer_client/pdd/sku_selection.py b/client/src/cmbuyer_client/pdd/sku_selection.py index 584c62e..2538aa7 100644 --- a/client/src/cmbuyer_client/pdd/sku_selection.py +++ b/client/src/cmbuyer_client/pdd/sku_selection.py @@ -18,6 +18,8 @@ EXPECTED_UNIT_PRICE = "12.88" # 任务值不是页面判据;右侧是 v5 取证的唯一 accessibility 文案(空格/全角括号均有意义)。 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_UNROLLED_BOUNDS = "[372,1188][684,1587]" +_TARGET_SIZE_BOUNDS = "[439,1582][831,1667]" _ENTRY = "快要抢光 ¥ 12.88" _ENTRY_PROMOTION_LABEL = "快要抢光" _ENTRY_TEXT_BOUNDS = "[688,2184][1042,2253]" @@ -37,10 +39,11 @@ _COLOR_ONLY_SUMMARY = "请选择: 尺码" _S_SIZE_UI = "S(建议80-100)" _S_SUMMARY = f"已选: {_TARGET_COLOR_UI} {_S_SIZE_UI}" _TARGET_SUMMARY = f"已选: {_TARGET_COLOR_UI} {_TARGET_SIZE_UI}" -_REVEAL_NOT_PROVEN = "尺码仍在已取证视口外;受控显示动作尚未取证,已停止后续点击。" _BOUNDS = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$") _ROLLED_CURRENT_PRICE = "快卖完 ¥12.88" _ROLLED_ORIGINAL_PRICE = "¥29.88" +_REVEALED_CURRENT_PRICE = "限1件 ¥12.88 " +_REVEAL_GESTURE = (360, 1900, 360, 1300, 30) _BAD_PRICE_ROLE = ("提交订单", "支付", "优惠", "券", "会员", "补贴", "区间", "实付", "到手", "原价", "划线价", "最低", "低至", "起价", "下单", "先用后付", "预估") @@ -91,6 +94,7 @@ class SkuPanelDevice(Protocol): def dump_window_hierarchy(self) -> str: ... def tap_sku_entry(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: ... @@ -122,6 +126,7 @@ class _Node: class _PanelProfile(Enum): PANEL_OPEN_EMPTY = "panel_open_empty" COLOR_SELECTED_SIZE_HIDDEN = "color_selected_size_hidden" + SIZE_VISIBLE_UNSELECTED = "size_visible_unselected" SIZE_VISIBLE_NON_TARGET = "size_visible_non_target" TARGETS_SELECTED = "targets_selected" @@ -172,7 +177,7 @@ _PANEL_SPECS = ( "券前¥29.88", "[693,580][912,647]", ), _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, ), _PanelSpec( @@ -183,7 +188,7 @@ _PANEL_SPECS = ( "券前¥29.88", "[693,580][912,647]", ), _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, ), _PanelSpec( @@ -194,7 +199,7 @@ _PANEL_SPECS = ( "限1件 ¥12.88 ", "[396,580][675,647]", ), _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, ), _PanelSpec( @@ -205,7 +210,7 @@ _PANEL_SPECS = ( "限1件 ¥12.88 ", "[396,580][675,647]", ), _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, ), _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: def __init__(self, device: SkuPanelDevice, entry_wait_timeout_seconds: float = 0.2, entry_poll_interval_seconds: float = 0.2, monotonic_clock: Callable[[], float] = monotonic, @@ -241,9 +259,12 @@ class SkuSelectionFlow: raise ValueError("入口等待参数无效。") 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._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: + self._require_active() try: if parse_product_url(product_url).goods_id != EXPECTED_GOODS_ID: raise SkuSelectionError("商品不是已取证目标,已停止操作。") @@ -265,90 +286,175 @@ class SkuSelectionFlow: raise try: - self._pending = (before, _require_empty_panel) + self._pending = (before, _require_empty_panel, False) self._device.tap_sku_entry(entry.bounds) except BaseException as error: + self._terminal = True _annotate_sku_entry_failure(error, "sku_entry_click") raise try: self._wait_after_action(before, _require_empty_panel) except BaseException as error: + self._terminal = True _annotate_sku_entry_failure(error, "sku_entry_panel_verify") raise 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): raise SkuSelectionError("规格 UI 文案不是获准目标,已停止操作。") self._require_foreground() before = self._read_hierarchy() nodes = _parse_nodes(before) spec = _classify_panel(nodes) - profile = spec.profile - - if profile is _PanelProfile.PANEL_OPEN_EMPTY: - 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._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) + if spec.profile is _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN: + if not isinstance(spec.price_layout, _CurrentOnlyPriceLayout): + raise SkuSelectionError("规格显示前置不是已取证单价格布局,已停止操作。") + if not self._color_selected_by_flow: + raise SkuSelectionError("目标颜色不是本次 Flow 精确选中,未执行 reveal。") return - if profile is _PanelProfile.TARGETS_SELECTED: - return - raise SkuSelectionError("规格面板状态不属于已取证 profile,已停止操作。") + if spec.profile is not _PanelProfile.PANEL_OPEN_EMPTY or not isinstance( + spec.price_layout, _CurrentOnlyPriceLayout + ): + 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: + self._require_active() return _unit_price(self._verified_nodes()) def verify_target_selection_and_read_price(self, selection: SkuSelection) -> str: + self._require_active() if selection != SkuSelection(_TARGET_COLOR_UI, _TARGET_SIZE_UI): raise SkuSelectionError("规格 UI 文案不是获准目标,已停止读取。") nodes = self._verified_nodes() return _unit_price(nodes) def exit_sku_panel_safely(self) -> None: + self._require_active() self._require_foreground() before = self._read_hierarchy() - _classify_panel(_parse_nodes(before)) - self._device.leave_sku_panel() + # 截图重证与 Back 之间仍可能漂移;只有目标双规格和已验单价同时保持时才允许返回。 + _unit_price(_parse_nodes(before)) + try: + self._device.leave_sku_panel() + except BaseException: + self._terminal = True + raise deadline = self._clock() + self._entry_timeout - while True: - self._require_foreground() - raw = self._read_hierarchy() - if raw != before: - try: - _classify_panel(_parse_nodes(raw)) - except SkuSelectionError: - return - remaining = deadline - self._clock() - if remaining <= 0: - raise SkuSelectionError("安全退出后未确认离开规格面板,未重试返回。") - self._sleep(min(self._poll, remaining)) + try: + while True: + self._require_foreground() + raw = self._read_hierarchy() + if raw != before: + try: + _classify_panel(_parse_nodes(raw)) + except SkuSelectionError: + self._terminal = True + return + remaining = deadline - self._clock() + if remaining <= 0: + raise SkuSelectionError("安全退出后未确认离开规格面板,未重试返回。") + self._sleep(min(self._poll, remaining)) + except BaseException: + self._terminal = True + raise def reconcile_pending_action(self) -> None: """仅只读调和一次已发出但尚未得到后置条件确认的动作。""" if self._pending is None: return - before, condition = self._pending - self._wait_after_action(before, condition) + before, condition, require_stable = self._pending + 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]: deadline, stable = self._clock() + self._entry_timeout, None @@ -380,19 +486,31 @@ class SkuSelectionFlow: raise SkuSelectionError("等待已取证规格入口超时,未执行点击。") 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 + stable: Any = None + has_stable = False while True: self._require_foreground() raw = self._read_hierarchy() if raw != previous: nodes = _parse_nodes(raw) try: - condition(nodes) - self._pending = None - return nodes + projection = condition(nodes) + if not require_stable or (has_stable and projection == stable): + self._pending = None + return nodes + stable = projection + has_stable = True except SkuSelectionError: - pass + stable = None + has_stable = False remaining = deadline - self._clock() if remaining <= 0: raise SkuSelectionError("动作后页面未在限定时间内满足已取证后置条件,未重试动作。") @@ -445,6 +563,12 @@ def _classify_panel(nodes: list[_Node]) -> _PanelSpec: except SkuSelectionError: continue matches.append(spec) + try: + _revealed_unselected_projection(nodes) + except SkuSelectionError: + pass + else: + matches.append(_REVEALED_UNSELECTED_SPEC) if len(matches) != 1: raise SkuSelectionError("规格面板不符合唯一完整取证 profile,已停止操作。") 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]) +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: spec = _classify_panel(nodes) 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: profile = _classify_panel(nodes).profile - expected_profile = _PanelProfile.TARGETS_SELECTED if selected else _PanelProfile.SIZE_VISIBLE_NON_TARGET - if profile is not expected_profile: + expected_profiles = ( + {_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。") 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 不唯一。", ) @@ -825,7 +1124,7 @@ def _require_color_subtree(color: _Node, spec: _PanelSpec) -> list[_Node]: selected = str(spec.color_selected).lower() children = [child for child in color.element if child.tag == "node"] 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: raise SkuSelectionError("目标颜色完整卡片子树数量漂移。") child_nodes = [node for node in _walk_direct_children(color)] @@ -869,7 +1168,7 @@ def _require_color_action_chain( ) -> None: if color.parent is not color_region: raise SkuSelectionError("目标颜色 action 不属于已取证颜色容器。") - if spec.color_bounds == "[372,1188][684,1587]": + if spec.color_bounds == _TARGET_COLOR_UNROLLED_BOUNDS: expected = ( ("android.widget.FrameLayout", "[0,1188][1080,2046]"), ("android.widget.LinearLayout", "[0,1188][1080,2052]"), diff --git a/client/src/cmbuyer_client/pdd/sku_selection_runner.py b/client/src/cmbuyer_client/pdd/sku_selection_runner.py index 2509143..763d99c 100644 --- a/client/src/cmbuyer_client/pdd/sku_selection_runner.py +++ b/client/src/cmbuyer_client/pdd/sku_selection_runner.py @@ -31,6 +31,10 @@ from .sku_selection import ( SkuSelectionError, SkuSelectionFlow, _SKU_ENTRY_FAILURE_STAGES, + _REVEAL_GESTURE, + _ENTRY_TEXT_BOUNDS, + _TARGET_COLOR_UNROLLED_BOUNDS, + _TARGET_SIZE_BOUNDS, _action_bounds, _annotate_sku_entry_failure, _safe_sku_entry_failure_stage, @@ -135,9 +139,9 @@ class SkuSelectionRunResult: class UiautomatorSkuPanelAdapter(SkuPanelDevice): - """把 uiautomator2 缩为 T-103 所需的读取与三种命名操作。 + """把 uiautomator2 缩为 T-103 所需的读取与四种命名操作。 - ``tap_sku_entry``、``tap_sku_option`` 和 ``leave_sku_panel`` 是仅有的状态改变方法; + 四个命名方法是仅有的状态改变入口;reveal 的手势参数固定且不向 Flow 暴露; 坐标由 Flow 和本类双重检查后才计算中心点,每次调用只执行一次底层动作。 """ @@ -147,7 +151,14 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice): self._device = device self._timeout_seconds = timeout_seconds 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._back_rpc_outcome = "not_attempted" @property def entry_was_tapped(self) -> bool: @@ -159,6 +170,38 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice): def left_panel(self) -> bool: 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]: value = self._call("app_info", package_name) if not isinstance(value, dict): @@ -178,19 +221,46 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice): return value def tap_sku_entry(self, bounds: str) -> None: + if self._entry_was_tapped: + raise SkuSelectionDeviceAdapterError("规格入口已经尝试过,拒绝重试。") # 超时也可能表示底层事件已经送达;必须先封存 attempt,后续绝不重试该入口。 self._entry_was_tapped = True + self._entry_bounds_attempted = bounds + self._entry_rpc_outcome = "ambiguous" self._tap_bounds_once(bounds) + self._entry_rpc_outcome = "completed" 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._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: if self._left_panel: raise SkuSelectionDeviceAdapterError("规格面板已经执行过返回,已停止操作。") # 底层调用即使报错也可能已把返回事件送达;先封存本次机会,finally 不得再次返回。 self._left_panel = True + self._back_rpc_outcome = "ambiguous" self._call("jsonrpc_call", "pressKey", ["back"], timeout=self._timeout_seconds) + self._back_rpc_outcome = "completed" def capture_screenshot(self) -> str: value = self._call("jsonrpc_call", "takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds) @@ -312,9 +382,11 @@ class SkuSelectionRunner: stage = "safe_exit" flow.exit_sku_panel_safely() + _require_completed_action_audit(adapter) + stage = "publish" 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", ) # Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。 @@ -340,11 +412,10 @@ class SkuSelectionRunner: _annotate_mapped_failure(mapped, error, stage) raise mapped from error finally: - # 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。 - if flow is not None and adapter is not None and adapter.entry_was_tapped and not adapter.left_panel: + # 结果不明只允许读回 pending;失败路径绝不继续后续动作或自动 Back。 + if flow is not None: try: flow.reconcile_pending_action() - flow.exit_sku_panel_safely() except (SkuSelectionRunError, SkuSelectionError): pass @@ -411,9 +482,42 @@ def _require_screenshot_size(screenshot_path: Path) -> None: 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。""" + option_outcomes = dict(adapter.option_rpc_outcomes) return { "schema_version": 1, "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}, "unit_price": EXPECTED_UNIT_PRICE, "selection_status": "restored", - "panel_status": "verified", - "safe_exit": "completed", + "panel_status": "verified_before_back", + "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", "channel": "wifi" if ":" in serial else "usb", "serial_sha256": sha256(serial.encode("utf-8")).hexdigest(), diff --git a/client/tests/pdd/test_sku_reveal_spike.py b/client/tests/pdd/test_sku_reveal_spike.py index bb816de..1458f64 100644 --- a/client/tests/pdd/test_sku_reveal_spike.py +++ b/client/tests/pdd/test_sku_reveal_spike.py @@ -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.pdd.sku_reveal_spike import ( _annotate_reveal_failure, - _candidate_projection, - _RevealEvidenceAdapter, safe_reveal_failure_stage, SkuRevealSpikeCapturer, SkuRevealSpikeError, - _require_safe_reveal_path, ) from cmbuyer_client.pdd.sku_selection import ( SkuSelectionError, _annotate_sku_entry_failure, _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: - root = ElementTree.fromstring(_candidate_unselected()) + root = ElementTree.fromstring(_candidate_current_only()) target = next( 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: - root = ElementTree.fromstring(_candidate_unselected()) + root = ElementTree.fromstring(_candidate_current_only()) parents = {child: parent for parent in root.iter() for child in parent} submit = next( node @@ -379,6 +381,13 @@ def _candidate_with_submit_risk(kind: str) -> str: parent.append(ElementTree.fromstring(ElementTree.tostring(submit, encoding="unicode"))) elif kind == "moved_up": 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: raise AssertionError(kind) return ElementTree.tostring(root, encoding="unicode") @@ -416,9 +425,9 @@ def _png() -> str: class _FakeDevice: def __init__(self) -> None: self.hierarchy = "" - self.empty_hierarchy = _EMPTY - self.color_hierarchy = _COLOR_ONLY - self.after_hierarchy = _candidate_unselected() + self.empty_hierarchy = _CURRENT_ONLY_EMPTY + self.color_hierarchy = _CURRENT_ONLY_COLOR + self.after_hierarchy = _candidate_current_only() self.calls: list[tuple[object, ...]] = [] self.swipe_error = False @@ -545,8 +554,9 @@ class SkuRevealSpikeTests(unittest.TestCase): self.assertTrue((target / "after" / "hierarchy.xml").is_file()) def test_candidate_price_variants_reject_hybrid_duplicate_unknown_and_parent_drift(self) -> None: - # 两个真实嵌套 variant 各自唯一命中;任意混搭、额外子节点或父子关系漂移均拒绝。 - _candidate_projection(_parse_nodes(_candidate_unselected())) + # 生产只放行 current-only;历史 dual 及任意混搭、额外节点、父链漂移均拒绝。 + with self.assertRaises(SkuSelectionError): + _candidate_projection(_parse_nodes(_candidate_unselected())) _candidate_projection(_parse_nodes(_candidate_current_only())) for variant in ("dual", "current_only"): 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()) 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): 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] with TemporaryDirectory() as directory: target = Path(directory) / "evidence" - with self.assertRaises(SkuRevealSpikeError) as raised: + with self.assertRaises((SkuRevealSpikeError, SkuSelectionError)) as raised: self._capturer(device).capture( "192.168.0.173:5555", "937122477375", @@ -850,7 +860,7 @@ class SkuRevealSpikeTests(unittest.TestCase): "not-a-bound", ), ): - with self.subTest(), self.assertRaises(SkuRevealSpikeError): + with self.subTest(), self.assertRaises((SkuRevealSpikeError, SkuSelectionError)): _require_safe_reveal_path(_parse_nodes(hierarchy)) diff --git a/client/tests/pdd/test_sku_selection.py b/client/tests/pdd/test_sku_selection.py index 586a60f..e718ca3 100644 --- a/client/tests/pdd/test_sku_selection.py +++ b/client/tests/pdd/test_sku_selection.py @@ -3,8 +3,10 @@ from __future__ import annotations import ast import base64 from contextlib import redirect_stderr +from functools import lru_cache from io import BytesIO import importlib.util +import json from pathlib import Path from tempfile import TemporaryDirectory import unittest @@ -49,6 +51,14 @@ _TASK_SIZE = "M(建议100-115)" _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: image = Image.new("RGB", (1080, 2376), "white") raw = BytesIO() @@ -59,8 +69,9 @@ def _png_base64() -> str: class _RawDevice: def __init__(self, hierarchy: str = _PRODUCT_PAGE, screenshot: str | None = None) -> None: self.hierarchy = hierarchy - self.panel_hierarchy = _EMPTY_FIXTURE.read_text(encoding="utf-8") - self.color_hierarchy = _COLOR_FIXTURE.read_text(encoding="utf-8") + self.panel_hierarchy = _CURRENT_ONLY_EMPTY_FIXTURE.read_text(encoding="utf-8") + self.color_hierarchy = _CURRENT_ONLY_COLOR_FIXTURE.read_text(encoding="utf-8") + self.revealed_hierarchy = _revealed_current_only() self.version = "8.17.0" self.package = "com.xunmeng.pinduoduo" self.screenshot = _png_base64() if screenshot is None else screenshot @@ -89,6 +100,11 @@ class _RawDevice: raise AssertionError(params) self._apply_tap(int(params[0]), int(params[1])) return "" + if method == "swipe": + if params != [360, 1900, 360, 1300, 30]: + raise AssertionError(params) + self.hierarchy = self.revealed_hierarchy + return "" raise AssertionError(method) def _apply_tap(self, x: int, y: int) -> None: @@ -405,25 +421,223 @@ class SkuSelectionFlowTests(unittest.TestCase): flow.open_sku_panel(_TARGET_URL) 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() flow = _flow(device) 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"), []) restored = _RawDevice(_S_FIXTURE.read_text(encoding="utf-8")) restored_flow = _flow(restored) 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)]) + 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: device = _RawDevice() @@ -432,15 +646,15 @@ class SkuSelectionFlowTests(unittest.TestCase): flow = _flow(device) 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)) - self.assertIs(matched.profile, _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN) + self.assertIs(matched.profile, _PanelProfile.TARGETS_SELECTED) self.assertEqual( matched.price_layout.__class__.__name__, - "_CurrentOnlyPriceLayout", + "_DualPriceLayout", ) 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)) 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"), []) 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) device.select_alternates() flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE)) - self.assertEqual( - _tap_centers(device), - [(865, 2218), (635, 1624)], - ) + self.assertEqual(_tap_centers(device), [(865, 2218), (635, 1624)]) + self.assertEqual(_actions(device, "swipe"), []) 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"): @@ -876,7 +1090,7 @@ class SkuSelectionFlowTests(unittest.TestCase): _flow(_RawDevice(clickable_parent)).read_sku_unit_price() 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(SkuPanelDevice.__dict__)) 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")) 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) + 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: now = [0.0] @@ -947,22 +1163,24 @@ class SkuSelectionFlowTests(unittest.TestCase): class _CompletedFlow: - """仅隔离 runner 文件发布测试;生产 Flow 在 reveal 取证前仍必须停止。""" + """仅隔离 runner 文件发布测试,同时必须形成完整动作审计链。""" 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 + self.device.tap_sku_entry("[688,2184][1042,2253]") 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: return "12.88" def exit_sku_panel_safely(self) -> None: - return None + self.device.leave_sku_panel() def reconcile_pending_action(self) -> None: return None @@ -989,15 +1207,55 @@ class SkuSelectionRunnerTests(unittest.TestCase): self.assertEqual(result.unit_price, "12.88") manifest = result.manifest_path.read_text(encoding="utf-8") + manifest_data = json.loads(manifest) self.assertTrue(result.screenshot_path.is_file()) self.assertNotIn("device-1", manifest) self.assertNotIn("hierarchy", manifest) self.assertNotIn("已选", manifest) self.assertIn('"unit_price": "12.88"', manifest) self.assertIn('"selection_status": "restored"', manifest) - self.assertIn('"panel_status": "verified"', manifest) - self.assertIn('"safe_exit": "completed"', manifest) + self.assertIn('"panel_status": "verified_before_back"', manifest) + self.assertIn('"back_attempts": 1', manifest) + self.assertIn('"back_rpc_outcome": "completed"', manifest) + self.assertIn('"post_exit_status": "human_review_required"', manifest) + self.assertNotIn('"safe_exit"', manifest) + self.assertEqual( + manifest_data["actions"], + { + "sku_entry": {"attempts": 1, "rpc_outcome": "completed"}, + "target_color": {"attempts": 1, "rpc_outcome": "completed"}, + "size_reveal": {"attempts": 1, "rpc_outcome": "completed"}, + "target_size": {"attempts": 1, "rpc_outcome": "completed"}, + "back": {"attempts": 1, "rpc_outcome": "completed"}, + }, + ) 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"), []) 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.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: now = [0.0] class SequenceDevice(_RawDevice): @@ -1276,8 +1562,48 @@ class SkuSelectionRunnerTests(unittest.TestCase): with self.assertRaises(SkuSelectionError): _flow(device).exit_sku_panel_safely() 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.panel_hierarchy = _EMPTY_FIXTURE.read_text(encoding="utf-8") + device.color_hierarchy = _COLOR_FIXTURE.read_text(encoding="utf-8") with TemporaryDirectory() as temporary: target = Path(temporary) / "out" with self.assertRaises(SkuSelectionError): @@ -1285,7 +1611,8 @@ class SkuSelectionRunnerTests(unittest.TestCase): self.assertFalse(target.exists()) self.assertFalse((target / "manifest.json").exists()) 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: class OptionTimeoutDevice(_RawDevice): @@ -1300,7 +1627,7 @@ class SkuSelectionRunnerTests(unittest.TestCase): raise TimeoutError("uncertain option") 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: device = OptionTimeoutDevice(delivered) adb = _FakeAdb(); device.hierarchy = "" @@ -1336,7 +1663,7 @@ class SkuSelectionRunnerTests(unittest.TestCase): with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionRunError): self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result") self.assertEqual(len(_actions(device, "click")), 1) - self.assertEqual(len(_actions(device, "pressKey")), 1) + self.assertEqual(len(_actions(device, "pressKey")), 0) class SkuSelectionCliTests(unittest.TestCase):