From b49a9b4abebc2684e9cfd175d3c9667d438ffaec Mon Sep 17 00:00:00 2001
From: QiuSW <105186638@qq.com>
Date: Tue, 4 Aug 2026 17:47:58 +0800
Subject: [PATCH] feat(client): implement verified SKU selection flow
---
client/scripts/run_t103_sku_selection.py | 84 +++
client/src/cmbuyer_client/pdd/__init__.py | 12 +-
.../src/cmbuyer_client/pdd/sku_selection.py | 371 +++++++++++
.../pdd/sku_selection_runner.py | 348 ++++++++++
.../tests/pdd/fixtures/sku_panel_8_17_0.xml | 26 +
client/tests/pdd/test_sku_selection.py | 619 ++++++++++++++++++
docs/tasks/T-103.md | 1 +
7 files changed, 1459 insertions(+), 2 deletions(-)
create mode 100644 client/scripts/run_t103_sku_selection.py
create mode 100644 client/src/cmbuyer_client/pdd/sku_selection.py
create mode 100644 client/src/cmbuyer_client/pdd/sku_selection_runner.py
create mode 100644 client/tests/pdd/fixtures/sku_panel_8_17_0.xml
create mode 100644 client/tests/pdd/test_sku_selection.py
diff --git a/client/scripts/run_t103_sku_selection.py b/client/scripts/run_t103_sku_selection.py
new file mode 100644
index 0000000..7438acf
--- /dev/null
+++ b/client/scripts/run_t103_sku_selection.py
@@ -0,0 +1,84 @@
+"""恢复 T-103 已取证目标规格、验证现价并保存本地原始截图。"""
+
+from __future__ import annotations
+
+import argparse
+from math import isfinite
+from pathlib import Path
+import sys
+
+
+CLIENT_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(CLIENT_ROOT / "src"))
+
+from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
+from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
+from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
+from cmbuyer_client.pdd.sku_selection import EXPECTED_GOODS_ID, SkuSelectionError, TASK_TO_UI_SELECTION
+from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError, SkuSelectionRunner
+
+
+def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="恢复 T-103 已取证规格并保存本地原始截图。")
+ parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
+ parser.add_argument("--url", required=True, help="唯一 canonical goods.html?goods_id= 直链。")
+ parser.add_argument("--color", required=True, help="T-103 任务颜色值。")
+ parser.add_argument("--size", required=True, help="T-103 任务尺码值。")
+ parser.add_argument("--output-dir", required=True, type=Path, help="新建本地目录;不得覆盖已有目录。")
+ parser.add_argument("--timeout", type=float, default=10.0, help="ADB 与设备 RPC 超时(秒)。")
+ parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
+ return parser.parse_args(argv)
+
+
+def validate_arguments(arguments: argparse.Namespace) -> None:
+ if not isinstance(arguments.serial, str) or not arguments.serial.strip():
+ raise ValueError("必须显式提供非空 --serial。")
+ if not isinstance(arguments.timeout, (int, float)) or isinstance(arguments.timeout, bool) or arguments.timeout <= 0 or not isfinite(arguments.timeout):
+ raise ValueError("--timeout 必须是大于 0 的有限数值。")
+ link = parse_product_url(arguments.url)
+ if link.goods_id != EXPECTED_GOODS_ID:
+ raise ValueError("--url 不是 T-103 已取证商品。")
+ if (arguments.color, arguments.size) not in TASK_TO_UI_SELECTION:
+ raise ValueError("--color 与 --size 必须是 T-103 已取证任务值。")
+
+
+def main(argv: list[str] | None = None) -> int:
+ arguments = parse_arguments(argv)
+ try:
+ validate_arguments(arguments)
+ except (ValueError, ProductUrlError) as error:
+ print(f"失败:{error}", file=sys.stderr)
+ return 2
+
+ try:
+ import adbutils
+ import uiautomator2 as u2
+ except ImportError:
+ print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
+ return 2
+
+ runner = SkuSelectionRunner(
+ AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout),
+ NoReconnectUiautomatorConnector(adbutils.AdbClient(socket_timeout=arguments.timeout).device_list, u2.connect),
+ timeout_seconds=arguments.timeout,
+ )
+ try:
+ result = runner.run(arguments.serial, arguments.url, arguments.color, arguments.size, arguments.output_dir)
+ except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError) as error:
+ # Flow 可能来自测试替身或未来实现;CLI 不回显任何异常正文,避免泄露节点树或页面文本。
+ print("规格恢复失败:已停止,未发布本地证据目录。", file=sys.stderr)
+ return 1
+ except OSError:
+ print("规格恢复失败:无法创建或发布本地证据目录。", file=sys.stderr)
+ return 1
+
+ print(f"规格恢复完成:{result.output_directory}")
+ print(f"manifest:{result.manifest_path}")
+ print(f"目标规格:{arguments.color} / {arguments.size}")
+ print(f"确认单价:{result.unit_price}")
+ print("页面对应性:请人工核对本地原始截图。")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/client/src/cmbuyer_client/pdd/__init__.py b/client/src/cmbuyer_client/pdd/__init__.py
index 389db9b..4db8f44 100644
--- a/client/src/cmbuyer_client/pdd/__init__.py
+++ b/client/src/cmbuyer_client/pdd/__init__.py
@@ -1,15 +1,23 @@
-"""拼多多链接的受限打开与只读取证。
+"""拼多多链接的受限打开、只读取证与经取证的规格面板选择。
-此包不提供页面选择器、输入、滑动、下单或支付能力。
+此包不提供通用页面选择器、输入、滑动或任何订单动作。
"""
from .product_open import ProductOpenCapturer, ProductOpenResult
from .product_url import ProductUrl, ProductUrlError, parse_product_url
+from .sku_selection import SkuSelection, SkuSelectionError, SkuSelectionFlow
+from .sku_selection_runner import SkuSelectionRunError, SkuSelectionRunResult, SkuSelectionRunner
__all__ = [
"ProductOpenCapturer",
"ProductOpenResult",
"ProductUrl",
"ProductUrlError",
+ "SkuSelection",
+ "SkuSelectionError",
+ "SkuSelectionFlow",
+ "SkuSelectionRunError",
+ "SkuSelectionRunResult",
+ "SkuSelectionRunner",
"parse_product_url",
]
diff --git a/client/src/cmbuyer_client/pdd/sku_selection.py b/client/src/cmbuyer_client/pdd/sku_selection.py
new file mode 100644
index 0000000..6c99e98
--- /dev/null
+++ b/client/src/cmbuyer_client/pdd/sku_selection.py
@@ -0,0 +1,371 @@
+"""T-103:仅限已取证 PDD 8.17.0 的规格面板恢复。"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+import re
+from time import monotonic, sleep
+from typing import Any, Callable, Protocol
+from xml.etree import ElementTree
+
+from ..device.baseline import PDD_PACKAGE
+from .product_open import EXPECTED_PDD_VERSION
+from .product_url import parse_product_url
+
+EXPECTED_GOODS_ID = "937122477375"
+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()))
+_ENTRY = "快要抢光"
+_SIZE = "尺码"
+_W, _H = 1080, 2376
+_PRICE_PARENT = "[396,498][895,570]"
+_CURRENT = "[396,503][712,570]"
+_ORIGINAL = "[730,503][895,570]"
+_SUMMARY = "[396,654][1053,716]"
+_COLOR_REGION = "[36,1000][1080,1631]"
+_SIZE_LABEL = "[36,1654][114,1700]"
+_SIZE_HEADER = "[36,1637][1044,1718]"
+_SIZE_OPTIONS = "[36,1730][1044,2045]"
+_BOUNDS = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$")
+_PRICE = re.compile(r"^[^0-9¥¥]*[¥¥]([1-9][0-9]*\.[0-9]{2})$")
+_ORIGINAL_PRICE = re.compile(r"^[¥¥][1-9][0-9]*\.[0-9]{2}$")
+_BAD_PRICE_ROLE = ("提交订单", "支付", "优惠", "券", "会员", "补贴", "区间", "实付", "到手", "原价", "划线价", "最低", "低至", "起价", "下单", "先用后付", "预估")
+
+
+class SkuSelectionError(RuntimeError):
+ """已取证判据不成立时的脱敏停止。"""
+
+
+class SkuPanelDevice(Protocol):
+ def app_info(self, package_name: str) -> dict[str, Any]: ...
+ def app_current(self) -> dict[str, Any]: ...
+ def dump_window_hierarchy(self) -> str: ...
+ def tap_sku_entry(self, bounds: str) -> None: ...
+ def tap_sku_option(self, bounds: str) -> None: ...
+ def leave_sku_panel(self) -> None: ...
+
+
+@dataclass(frozen=True)
+class SkuSelection:
+ color: str
+ size: str
+
+
+def resolve_task_selection(color: str, size: str) -> SkuSelection:
+ mapped = TASK_TO_UI_SELECTION.get((color, size))
+ if mapped is None:
+ raise SkuSelectionError("规格任务值不是已取证的唯一目标,已停止操作。")
+ return SkuSelection(*mapped)
+
+
+@dataclass(frozen=True)
+class _Node:
+ element: ElementTree.Element
+ parent: "_Node | None"
+ @property
+ def text(self) -> str: return self.element.get("text", "")
+ @property
+ def desc(self) -> str: return self.element.get("content-desc", "")
+ @property
+ def bounds(self) -> str: return self.element.get("bounds", "")
+
+
+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,
+ sleep_function: Callable[[float], None] = sleep) -> None:
+ if entry_wait_timeout_seconds < 0 or entry_poll_interval_seconds <= 0:
+ 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
+
+ def open_sku_panel(self, product_url: str, pre_intent_hierarchy: str | None = None) -> None:
+ if parse_product_url(product_url).goods_id != EXPECTED_GOODS_ID:
+ raise SkuSelectionError("商品不是已取证目标,已停止操作。")
+ if pre_intent_hierarchy is not None:
+ previous_nodes = _parse_nodes(pre_intent_hierarchy)
+ if _eligible_entries(previous_nodes):
+ raise SkuSelectionError("intent 前页面已出现规格入口,已拒绝旧商品误点。")
+ entry, before = self._wait_for_entry(pre_intent_hierarchy)
+ _action_bounds(entry.bounds)
+ self._pending = (before, _panel)
+ self._device.tap_sku_entry(entry.bounds)
+ self._wait_after_action(before, _panel)
+
+ def select_sku_options(self, selection: SkuSelection) -> None:
+ if selection not in {SkuSelection(*item) for item in TASK_TO_UI_SELECTION.values()}:
+ raise SkuSelectionError("规格 UI 文案不是获准目标,已停止操作。")
+ initial = self._verified_nodes()
+ _option(initial, "color", selection.color); _option(initial, "size", selection.size)
+ _selected_label(initial, "color"); _selected_label(initial, "size")
+ self._restore("color", selection.color)
+ self._restore("size", selection.size)
+
+ def read_sku_unit_price(self) -> str:
+ return _unit_price(self._verified_nodes())
+
+ def verify_target_selection_and_read_price(self, selection: SkuSelection) -> str:
+ nodes = self._verified_nodes()
+ _selected(nodes, "color", selection.color)
+ _selected(nodes, "size", selection.size)
+ return _unit_price(nodes)
+
+ def exit_sku_panel_safely(self) -> None:
+ self._require_foreground()
+ before = self._read_hierarchy()
+ _panel(_parse_nodes(before))
+ self._device.leave_sku_panel()
+ deadline = self._clock() + self._entry_timeout
+ while True:
+ self._require_foreground()
+ raw = self._read_hierarchy()
+ if raw != before:
+ try:
+ _panel(_parse_nodes(raw))
+ except SkuSelectionError:
+ return
+ remaining = deadline - self._clock()
+ if remaining <= 0:
+ raise SkuSelectionError("安全退出后未确认离开规格面板,未重试返回。")
+ self._sleep(min(self._poll, remaining))
+
+ def reconcile_pending_action(self) -> None:
+ """仅只读调和一次已发出但尚未得到后置条件确认的动作。"""
+ if self._pending is None:
+ return
+ before, condition = self._pending
+ self._wait_after_action(before, condition)
+
+ def _restore(self, dimension: str, expected: str) -> None:
+ self._require_foreground()
+ before = self._read_hierarchy()
+ nodes = _panel(_parse_nodes(before))
+ target = _option(nodes, dimension, expected)
+ if _selected_label(nodes, dimension) == expected:
+ return
+ _action_bounds(target.bounds)
+ condition: Callable[[list[_Node]], Any]
+ if dimension == "color":
+ condition = lambda refreshed: _post_color(refreshed, expected)
+ else:
+ condition = lambda refreshed: _post_all_targets(refreshed, expected)
+ self._pending = (before, condition)
+ self._device.tap_sku_option(target.bounds)
+ if dimension == "color":
+ self._wait_after_action(before, condition)
+ else:
+ self._wait_after_action(before, condition)
+
+ def _wait_for_entry(self, previous: str | None) -> tuple[_Node, str]:
+ deadline, stable = self._clock() + self._entry_timeout, None
+ while True:
+ self._require_version()
+ current = self._device.app_current()
+ if isinstance(current, dict) and current.get("package") == PDD_PACKAGE:
+ raw = self._read_hierarchy()
+ entries = _eligible_entries(_parse_nodes(raw))
+ if len(entries) > 1:
+ raise SkuSelectionError("商品页规格入口不唯一,已停止操作。")
+ if len(entries) == 1 and raw != previous:
+ if stable == raw:
+ return entries[0], raw
+ stable = raw
+ else:
+ stable = None
+ else:
+ stable = None
+ remaining = deadline - self._clock()
+ if remaining <= 0:
+ raise SkuSelectionError("等待已取证规格入口超时,未执行点击。")
+ self._sleep(min(self._poll, remaining))
+
+ def _wait_after_action(self, previous: str, condition: Callable[[list[_Node]], Any]) -> list[_Node]:
+ deadline = self._clock() + self._entry_timeout
+ while True:
+ self._require_foreground()
+ raw = self._read_hierarchy()
+ if raw != previous:
+ nodes = _parse_nodes(raw)
+ try:
+ condition(nodes)
+ self._pending = None
+ return nodes
+ except SkuSelectionError:
+ pass
+ remaining = deadline - self._clock()
+ if remaining <= 0:
+ raise SkuSelectionError("动作后页面未在限定时间内满足已取证后置条件,未重试动作。")
+ self._sleep(min(self._poll, remaining))
+
+ def _verified_nodes(self) -> list[_Node]:
+ self._require_foreground()
+ return _panel(self._read_nodes())
+
+ def _require_version(self) -> None:
+ info = self._device.app_info(PDD_PACKAGE)
+ version = (info.get("versionName") or info.get("version_name")) if isinstance(info, dict) else None
+ if version != EXPECTED_PDD_VERSION:
+ raise SkuSelectionError("拼多多版本与已取证版本不一致,已停止操作。")
+
+ def _require_foreground(self) -> None:
+ self._require_version()
+ current = self._device.app_current()
+ if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
+ raise SkuSelectionError("拼多多不在前台,已停止操作。")
+
+ def _read_hierarchy(self) -> str:
+ try: raw = self._device.dump_window_hierarchy()
+ except Exception as error: raise SkuSelectionError("节点树读取失败,已停止操作。") from error
+ if not isinstance(raw, str) or not raw: raise SkuSelectionError("节点树不可用,已停止操作。")
+ return raw
+
+ def _read_nodes(self) -> list[_Node]: return _parse_nodes(self._read_hierarchy())
+
+
+def _parse_nodes(raw: str) -> list[_Node]:
+ try: root = ElementTree.fromstring(raw)
+ except ElementTree.ParseError as error: raise SkuSelectionError("节点树格式无效,已停止操作。") from error
+ if root.tag != "hierarchy": raise SkuSelectionError("节点树根节点无效,已停止操作。")
+ result: list[_Node] = []
+ def visit(element: ElementTree.Element, parent: _Node | None) -> None:
+ node = _Node(element, parent); result.append(node)
+ for child in element: visit(child, node)
+ visit(root, None)
+ return result
+
+
+def _panel(nodes: list[_Node]) -> list[_Node]:
+ parent = _one([n for n in nodes if n.bounds == _PRICE_PARENT], "规格面板价格区域不唯一,已停止操作。")
+ _one([n for n in nodes if n.parent is parent and n.bounds == _ORIGINAL and _readonly(n) and _ORIGINAL_PRICE.fullmatch(n.text)], "规格面板原价槽位不唯一,已停止操作。")
+ _one([n for n in nodes if n.bounds == _SUMMARY and _readonly(n) and n.text.startswith("已选:")], "规格面板已选摘要不唯一,已停止操作。")
+ _color_container(nodes); _size_container(nodes)
+ return nodes
+
+
+def _unit_price(nodes: list[_Node]) -> str:
+ parent = _one([n for n in nodes if n.bounds == _PRICE_PARENT], "规格面板价格区域不唯一,已停止读取。")
+ money = [n for n in nodes if n.parent is parent and _readonly(n) and any(mark in n.text for mark in "¥¥")]
+ if len(money) != 2: raise SkuSelectionError("规格面板金额槽位不唯一,已停止读取。")
+ current = _one([n for n in money if n.bounds == _CURRENT and not _clickable_ancestor(n) and not any(word in n.text for word in _BAD_PRICE_ROLE) and _PRICE.fullmatch(n.text)], "规格面板现价不唯一或不符合已取证槽位,已停止读取。")
+ if not any(n.bounds == _ORIGINAL and _ORIGINAL_PRICE.fullmatch(n.text) for n in money):
+ raise SkuSelectionError("规格面板原价槽位无效,已停止读取。")
+ match = _PRICE.fullmatch(current.text)
+ if match is None: raise SkuSelectionError("规格面板现价格式失效,已停止读取。")
+ return match.group(1)
+
+
+def _option(nodes: list[_Node], dimension: str, expected: str) -> _Node:
+ _panel(nodes)
+ return _one([n for n in _options(nodes, dimension) if _label(n) == expected], "规格选项不唯一或不是精确匹配,已停止操作。")
+
+
+def _selected(nodes: list[_Node], dimension: str, expected: str) -> None:
+ if _selected_label(nodes, dimension) != expected:
+ raise SkuSelectionError("规格选择后读回的 selected 文案不一致,已停止操作。")
+
+
+def _selected_label(nodes: list[_Node], dimension: str) -> str:
+ selected = [n for n in _options(nodes, dimension) if n.element.get("selected") == "true"]
+ label = _label(_one(selected, "规格维度没有唯一 selected 状态,已停止操作。"))
+ if label is None: raise SkuSelectionError("规格维度 selected 文案无效,已停止操作。")
+ return label
+
+
+def _post_color(nodes: list[_Node], expected: str) -> None:
+ _panel(nodes)
+ _selected(nodes, "color", expected)
+ _selected_label(nodes, "size")
+
+
+def _post_all_targets(nodes: list[_Node], expected_size: str) -> None:
+ _panel(nodes)
+ _selected(nodes, "color", _TARGET_COLOR_UI)
+ _selected(nodes, "size", expected_size)
+
+
+def _options(nodes: list[_Node], dimension: str) -> list[_Node]:
+ container = _color_container(nodes) if dimension == "color" else _size_container(nodes) if dimension == "size" else None
+ if container is None: raise SkuSelectionError("未知规格维度,已停止操作。")
+ candidates = [n for n in nodes if _descendant(n, container) and _contained(n, container) and _choice(n) and _label(n) is not None]
+ return [n for n in candidates if not _labeled_ancestor(n, candidates)]
+
+
+def _color_container(nodes: list[_Node]) -> _Node:
+ return _one([n for n in nodes if n.element.get("package") == PDD_PACKAGE and n.element.get("class") == "androidx.recyclerview.widget.RecyclerView" and n.bounds == _COLOR_REGION], "规格面板颜色容器不唯一,已停止操作。")
+
+
+def _size_container(nodes: list[_Node]) -> _Node:
+ label = _one([n for n in nodes if n.text == _SIZE and n.bounds == _SIZE_LABEL and _readonly(n)], "规格面板尺码标签不唯一,已停止操作。")
+ header = label.parent
+ if header is None or header.element.get("package") != PDD_PACKAGE or header.element.get("class") != "android.widget.LinearLayout" or header.bounds != _SIZE_HEADER or header.parent is None:
+ raise SkuSelectionError("规格面板尺码标题容器不符合已取证结构,已停止操作。")
+ return _one([n for n in nodes if n.parent is header.parent and n.element.get("package") == PDD_PACKAGE and n.element.get("class") == "android.widget.LinearLayout" and n.bounds == _SIZE_OPTIONS], "规格面板尺码选项容器不唯一,已停止操作。")
+
+
+def _label(node: _Node) -> str | None:
+ values = {value for value in (node.text, node.desc) if value}
+ return values.pop() if len(values) == 1 else None
+
+
+def _labeled_ancestor(node: _Node, candidates: list[_Node]) -> bool:
+ ids, parent = {id(n.element) for n in candidates}, node.parent
+ while parent is not None:
+ if id(parent.element) in ids and _label(parent) is not None: return True
+ parent = parent.parent
+ return False
+
+
+def _descendant(node: _Node, ancestor: _Node) -> bool:
+ parent = node.parent
+ while parent is not None:
+ if parent.element is ancestor.element: return True
+ parent = parent.parent
+ return False
+
+
+def _contained(node: _Node, container: _Node) -> bool:
+ left, top, right, bottom = _action_bounds(node.bounds)
+ outer_left, outer_top, outer_right, outer_bottom = _action_bounds(container.bounds)
+ return outer_left <= left < right <= outer_right and outer_top <= top < bottom <= outer_bottom
+
+
+def _clickable_ancestor(node: _Node) -> bool:
+ parent = node.parent
+ while parent is not None:
+ if parent.element.get("clickable") == "true": return True
+ parent = parent.parent
+ return False
+
+
+def _readonly(node: _Node) -> bool:
+ return node.element.get("package") == PDD_PACKAGE and node.element.get("class") == "android.widget.TextView" and node.element.get("clickable") == "false" and node.element.get("enabled") == "true" and node.element.get("visible-to-user") == "true"
+
+
+def _live(node: _Node) -> bool:
+ return node.element.get("package") == PDD_PACKAGE and node.element.get("clickable") == "true" and node.element.get("enabled") == "true" and node.element.get("visible-to-user") == "true" and bool(node.bounds)
+
+
+def _choice(node: _Node) -> bool:
+ return _live(node) and node.element.get("class") == "android.view.ViewGroup" and node.element.get("selected") in {"true", "false"}
+
+
+def _eligible_entries(nodes: list[_Node]) -> list[_Node]:
+ # 面板与底部硬拒绝区绝不能被误作商品页入口;入口仍只按经取证精确文案、包、状态判定。
+ if any(node.bounds == _PRICE_PARENT for node in nodes): return []
+ return [node for node in nodes if _live(node) and node.element.get("class") == "android.widget.TextView" and node.text == _ENTRY]
+
+
+def _action_bounds(bounds: str) -> tuple[int, int, int, int]:
+ match = _BOUNDS.fullmatch(bounds)
+ if match is None: raise SkuSelectionError("规格节点坐标格式无效,已停止操作。")
+ left, top, right, bottom = (int(item) for item in match.groups())
+ if not (0 <= left < right <= _W and 0 <= top < bottom <= _H):
+ raise SkuSelectionError("规格节点坐标不在已取证屏幕范围内,已停止操作。")
+ return left, top, right, bottom
+
+
+def _one(nodes: list[_Node], message: str) -> _Node:
+ if len(nodes) != 1: raise SkuSelectionError(message)
+ return nodes[0]
diff --git a/client/src/cmbuyer_client/pdd/sku_selection_runner.py b/client/src/cmbuyer_client/pdd/sku_selection_runner.py
new file mode 100644
index 0000000..5f6600e
--- /dev/null
+++ b/client/src/cmbuyer_client/pdd/sku_selection_runner.py
@@ -0,0 +1,348 @@
+"""T-103 真机运行边界:窄适配器、原始截图和无页面正文的摘要。"""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from hashlib import sha256
+import json
+from math import isfinite
+import os
+from pathlib import Path
+import shutil
+from time import monotonic
+from typing import Any
+from uuid import uuid4
+
+from PIL import Image, UnidentifiedImageError
+
+from adbutils.errors import AdbTimeout
+from uiautomator2.exceptions import HTTPTimeoutError
+
+from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
+from ..device.baseline import PDD_PACKAGE, SCREENSHOT_PARAMS, _save_base64_screenshot, _sha256_file
+from .product_open import EXPECTED_PDD_VERSION
+from .product_url import ProductUrl, parse_product_url
+from .sku_selection import (
+ EXPECTED_GOODS_ID,
+ EXPECTED_UNIT_PRICE,
+ SkuPanelDevice,
+ SkuSelectionError,
+ SkuSelectionFlow,
+ _action_bounds,
+ resolve_task_selection,
+)
+
+
+EXPECTED_DEVICE_MODEL = "PKG110"
+EXPECTED_ANDROID_VERSION = "16"
+EXPECTED_SCREEN_SIZE = (1080, 2376)
+
+
+class SkuSelectionRunError(RuntimeError):
+ """T-103 运行未完整完成;错误文本不携带设备或页面原文。"""
+
+
+class SkuSelectionRunTimeoutError(SkuSelectionRunError):
+ """设备 RPC 或操作超时。"""
+
+
+class SkuSelectionScreenshotError(SkuSelectionRunError):
+ """原始截图无法作为完整 PNG 原子发布。"""
+
+
+class SkuSelectionUnexpectedPriceError(SkuSelectionRunError):
+ """取证面板现价不是本任务已确认值。"""
+
+
+class SkuSelectionDeviceAdapterError(SkuSelectionRunError):
+ """第三方设备接口失败的脱敏映射。"""
+
+
+@dataclass(frozen=True)
+class SkuSelectionRunResult:
+ """已发布的截图和无页面正文 manifest 摘要。"""
+
+ output_directory: Path
+ screenshot_path: Path
+ manifest_path: Path
+ unit_price: str
+
+
+class UiautomatorSkuPanelAdapter(SkuPanelDevice):
+ """把 uiautomator2 缩为 T-103 所需的读取与三种命名操作。
+
+ ``tap_sku_entry``、``tap_sku_option`` 和 ``leave_sku_panel`` 是仅有的状态改变方法;
+ 坐标由 Flow 和本类双重检查后才计算中心点,每次调用只执行一次底层动作。
+ """
+
+ def __init__(self, device: Any, timeout_seconds: float) -> None:
+ if not _is_positive_finite(timeout_seconds):
+ raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
+ self._device = device
+ self._timeout_seconds = timeout_seconds
+ self._entry_was_tapped = False
+ self._left_panel = False
+
+ @property
+ def entry_was_tapped(self) -> bool:
+ """仅供运行器决定故障后的单次尽力返回,不是页面操作。"""
+
+ return self._entry_was_tapped
+
+ @property
+ def left_panel(self) -> bool:
+ return self._left_panel
+
+ def app_info(self, package_name: str) -> dict[str, Any]:
+ value = self._call("app_info", package_name)
+ if not isinstance(value, dict):
+ raise SkuSelectionDeviceAdapterError("无法读取应用版本,已停止操作。")
+ return value
+
+ def app_current(self) -> dict[str, Any]:
+ value = self._call("app_current")
+ if not isinstance(value, dict):
+ raise SkuSelectionDeviceAdapterError("无法读取前台应用,已停止操作。")
+ return value
+
+ def dump_window_hierarchy(self) -> str:
+ value = self._call("jsonrpc_call", "dumpWindowHierarchy", [False, 50], timeout=self._timeout_seconds)
+ if not isinstance(value, str):
+ raise SkuSelectionDeviceAdapterError("节点树读取失败,已停止操作。")
+ return value
+
+ def tap_sku_entry(self, bounds: str) -> None:
+ # 超时也可能表示底层事件已经送达;必须先封存 attempt,后续绝不重试该入口。
+ self._entry_was_tapped = True
+ self._tap_bounds_once(bounds)
+
+ def tap_sku_option(self, bounds: str) -> None:
+ self._tap_bounds_once(bounds)
+
+ def leave_sku_panel(self) -> None:
+ if self._left_panel:
+ raise SkuSelectionDeviceAdapterError("规格面板已经执行过返回,已停止操作。")
+ # 底层调用即使报错也可能已把返回事件送达;先封存本次机会,finally 不得再次返回。
+ self._left_panel = True
+ self._call("jsonrpc_call", "pressKey", ["back"], timeout=self._timeout_seconds)
+
+ def capture_screenshot(self) -> str:
+ value = self._call("jsonrpc_call", "takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds)
+ if not isinstance(value, str):
+ raise SkuSelectionScreenshotError("规格面板原始截图读取失败,未发布任何证据产物。")
+ return value
+
+ def display_size(self) -> tuple[int, int]:
+ value = self._call("window_size")
+ if not isinstance(value, tuple) or len(value) != 2 or any(not isinstance(item, int) for item in value):
+ raise SkuSelectionDeviceAdapterError("无法读取屏幕坐标空间,已停止操作。")
+ return value
+
+ def _tap_bounds_once(self, bounds: str) -> None:
+ left, top, right, bottom = _action_bounds(bounds)
+ center_x = left + (right - left) // 2
+ center_y = top + (bottom - top) // 2
+ self._call("jsonrpc_call", "click", [center_x, center_y], timeout=self._timeout_seconds)
+
+ def _call(self, method: str, *args: Any, **kwargs: Any) -> Any:
+ try:
+ operation = getattr(self._device, method)
+ return operation(*args, **kwargs)
+ except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
+ raise SkuSelectionRunTimeoutError("规格面板设备操作超时,已停止操作。") from error
+ except SkuSelectionRunError:
+ raise
+ except Exception as error:
+ raise SkuSelectionDeviceAdapterError("规格面板设备操作失败,已停止操作。") from error
+
+
+class SkuSelectionRunner:
+ """只运行 T-103 目标规格恢复、价格确认、原始截图和一次安全退出。"""
+
+ def __init__(
+ self,
+ adb_client: AdbClient,
+ connector: Callable[[str], Any],
+ timeout_seconds: float,
+ monotonic_clock: Callable[[], float] = monotonic,
+ ) -> None:
+ if not _is_positive_finite(timeout_seconds):
+ raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
+ self._adb_client = adb_client
+ self._connector = connector
+ self._timeout_seconds = timeout_seconds
+ self._monotonic_clock = monotonic_clock
+
+ def run(
+ self,
+ serial: str,
+ product_url: str,
+ task_color: str,
+ task_size: str,
+ output_directory: Path,
+ ) -> SkuSelectionRunResult:
+ link = parse_product_url(product_url)
+ if link.goods_id != EXPECTED_GOODS_ID:
+ raise SkuSelectionRunError("商品不是 T-103 已取证目标,已停止操作。")
+ selection = resolve_task_selection(task_color, task_size)
+ target = Path(output_directory)
+ _validate_new_target(target)
+
+ adapter: UiautomatorSkuPanelAdapter | None = None
+ flow: SkuSelectionFlow | None = None
+ staging = _prepare_staging(target)
+ deadline = self._monotonic_clock() + self._timeout_seconds
+ try:
+ inspection = self._adb_client.inspect(serial)
+ _require_expected_device(inspection)
+ adapter = UiautomatorSkuPanelAdapter(self._connector(serial), self._timeout_seconds)
+ _require_expected_version(adapter.app_info(PDD_PACKAGE))
+ if adapter.display_size() != EXPECTED_SCREEN_SIZE:
+ raise SkuSelectionRunError("设备不是已取证的竖屏坐标空间,已停止操作。")
+ pre_intent_hierarchy = adapter.dump_window_hierarchy()
+ # 固定 ACTION_VIEW、固定 PDD package 和 canonical goods_id;不接受任意 URL 或 shell。
+ self._adb_client.start_pdd_view_intent(serial, link.goods_id)
+
+ remaining = deadline - self._monotonic_clock()
+ if remaining <= 0:
+ raise SkuSelectionRunTimeoutError("等待规格入口超时,未执行点击。")
+ flow = SkuSelectionFlow(adapter, entry_wait_timeout_seconds=remaining)
+ flow.open_sku_panel(link.canonical_url, pre_intent_hierarchy)
+ flow.select_sku_options(selection)
+ unit_price = flow.verify_target_selection_and_read_price(selection)
+ if unit_price != EXPECTED_UNIT_PRICE:
+ raise SkuSelectionUnexpectedPriceError("规格面板现价不是本任务已确认值,已停止操作。")
+
+ screenshot_path = staging / "screenshot.png"
+ try:
+ _save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
+ _require_screenshot_size(screenshot_path)
+ except SkuSelectionRunError:
+ raise
+ except Exception as error:
+ raise SkuSelectionScreenshotError("规格面板原始截图保存失败,未发布任何证据产物。") from error
+
+ manifest_path = staging / "manifest.json"
+ # 截图可能落在动态页面切换边界;发布前必须用一棵更新节点树同时重证两维和现价。
+ final_price = flow.verify_target_selection_and_read_price(selection)
+ if final_price != EXPECTED_UNIT_PRICE:
+ raise SkuSelectionUnexpectedPriceError("截图后规格面板现价不是本任务已确认值,已停止操作。")
+ # 正常路径仍经 Flow 做最后一次前台和面板判定;返回操作只发生一次。
+ flow.exit_sku_panel_safely()
+ 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",
+ encoding="utf-8",
+ )
+ # Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。
+ os.rename(staging, target)
+ staging = None
+ except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError):
+ _clean_staging(staging)
+ raise
+ except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
+ _clean_staging(staging)
+ raise SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。") from error
+ except OSError as error:
+ _clean_staging(staging)
+ raise SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。") from error
+ except Exception as error:
+ _clean_staging(staging)
+ raise SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。") from error
+ finally:
+ # 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。
+ if flow is not None and adapter is not None and adapter.entry_was_tapped and not adapter.left_panel:
+ try:
+ flow.reconcile_pending_action()
+ flow.exit_sku_panel_safely()
+ except (SkuSelectionRunError, SkuSelectionError):
+ pass
+
+ return SkuSelectionRunResult(
+ output_directory=target,
+ screenshot_path=target / "screenshot.png",
+ manifest_path=target / "manifest.json",
+ unit_price=EXPECTED_UNIT_PRICE,
+ )
+
+
+def _is_positive_finite(value: object) -> bool:
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
+
+
+def _validate_new_target(target: Path) -> None:
+ if target.exists():
+ raise SkuSelectionRunError("输出目录已存在;为防止覆盖旧证据,已停止操作。")
+ if not target.name:
+ raise SkuSelectionRunError("输出目录必须是明确的新目录。")
+
+
+def _prepare_staging(target: Path) -> Path:
+ staging: Path | None = None
+ try:
+ target.parent.mkdir(parents=True, exist_ok=True)
+ staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
+ staging.mkdir()
+ probe = staging / ".write-probe"
+ probe.write_bytes(b"ok")
+ probe.unlink()
+ return staging
+ except OSError as error:
+ _clean_staging(staging)
+ raise SkuSelectionRunError("输出目录不可写,已停止操作。") from error
+
+
+def _clean_staging(staging: Path | None) -> None:
+ if staging is not None and staging.exists():
+ shutil.rmtree(staging)
+
+
+def _require_expected_version(app_info: object) -> str:
+ version = (app_info.get("versionName") or app_info.get("version_name")) if isinstance(app_info, dict) else None
+ if version != EXPECTED_PDD_VERSION:
+ raise SkuSelectionRunError("拼多多版本与已取证版本不一致,已停止操作。")
+ return version
+
+
+def _require_expected_device(inspection: DeviceInspection) -> None:
+ if inspection.model != EXPECTED_DEVICE_MODEL or inspection.android_version != EXPECTED_ANDROID_VERSION:
+ raise SkuSelectionRunError("设备型号或 Android 版本不是已取证组合,已停止操作。")
+
+
+def _require_screenshot_size(screenshot_path: Path) -> None:
+ try:
+ with Image.open(screenshot_path) as image:
+ image.load()
+ if image.size != EXPECTED_SCREEN_SIZE:
+ raise SkuSelectionScreenshotError("原始截图坐标空间不是已取证尺寸,未发布任何证据产物。")
+ except SkuSelectionRunError:
+ raise
+ except (UnidentifiedImageError, OSError) as 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]:
+ """仅写可审计摘要;原始 serial、节点树、页面文案和实际截图内容均不写入 manifest。"""
+
+ return {
+ "schema_version": 1,
+ "captured_at": datetime.now(UTC).isoformat(),
+ "operation": "t103-sku-selection",
+ "product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
+ "target_selection": {"color": task_color, "size": task_size},
+ "unit_price": EXPECTED_UNIT_PRICE,
+ "selection_status": "restored",
+ "panel_status": "verified",
+ "safe_exit": "completed",
+ "page_identity": "human_review_required",
+ "channel": "wifi" if ":" in serial else "usb",
+ "serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
+ "device": {
+ "model": inspection.model,
+ "android_version": inspection.android_version,
+ "pdd_package": PDD_PACKAGE,
+ "pdd_version": EXPECTED_PDD_VERSION,
+ },
+ "artifacts": [{"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)}],
+ }
diff --git a/client/tests/pdd/fixtures/sku_panel_8_17_0.xml b/client/tests/pdd/fixtures/sku_panel_8_17_0.xml
new file mode 100644
index 0000000..fd03ef0
--- /dev/null
+++ b/client/tests/pdd/fixtures/sku_panel_8_17_0.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/tests/pdd/test_sku_selection.py b/client/tests/pdd/test_sku_selection.py
new file mode 100644
index 0000000..002e901
--- /dev/null
+++ b/client/tests/pdd/test_sku_selection.py
@@ -0,0 +1,619 @@
+from __future__ import annotations
+
+import ast
+import base64
+from contextlib import redirect_stderr
+from io import BytesIO
+import importlib.util
+from pathlib import Path
+from tempfile import TemporaryDirectory
+import unittest
+from unittest.mock import patch
+from xml.etree import ElementTree
+
+from PIL import Image
+
+import cmbuyer_client.pdd as pdd
+import cmbuyer_client.pdd.sku_selection_runner as runner_module
+from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
+from cmbuyer_client.pdd import SkuSelectionError, SkuSelectionFlow, SkuSelectionRunner
+from cmbuyer_client.pdd.sku_selection import SkuPanelDevice, _action_bounds, resolve_task_selection
+from cmbuyer_client.pdd.sku_selection_runner import (
+ SkuSelectionDeviceAdapterError,
+ SkuSelectionRunError,
+ SkuSelectionScreenshotError,
+ UiautomatorSkuPanelAdapter,
+)
+
+
+_FIXTURE = Path(__file__).with_name("fixtures") / "sku_panel_8_17_0.xml"
+_TARGET_URL = "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"
+_TASK_COLOR = "黑色CHA(纯棉)"
+_TASK_SIZE = "M(建议100-115)"
+_PRODUCT_PAGE = """"""
+
+
+def _png_base64() -> str:
+ image = Image.new("RGB", (1080, 2376), "white")
+ raw = BytesIO()
+ image.save(raw, format="PNG")
+ return base64.b64encode(raw.getvalue()).decode("ascii")
+
+
+class _RawDevice:
+ def __init__(self, hierarchy: str = _PRODUCT_PAGE, screenshot: str | None = None) -> None:
+ self.hierarchy = hierarchy
+ self.panel_hierarchy = _FIXTURE.read_text(encoding="utf-8")
+ self.version = "8.17.0"
+ self.package = "com.xunmeng.pinduoduo"
+ self.screenshot = _png_base64() if screenshot is None else screenshot
+ self.calls: list[tuple[object, ...]] = []
+ self.fail_color_readback = False
+
+ def app_info(self, package_name: str) -> dict[str, str]:
+ self.calls.append(("app_info", package_name))
+ return {"versionName": self.version}
+
+ def app_current(self) -> dict[str, str]:
+ self.calls.append(("app_current",))
+ return {"package": self.package}
+
+ def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
+ self.calls.append(("jsonrpc", method, params, timeout))
+ if method == "dumpWindowHierarchy":
+ return self.hierarchy
+ if method == "takeScreenshot":
+ return self.screenshot
+ if method == "pressKey":
+ self.hierarchy = ""
+ return ""
+ if method == "click":
+ if not isinstance(params, list) or len(params) != 2:
+ raise AssertionError(params)
+ self._apply_tap(int(params[0]), int(params[1]))
+ return ""
+ raise AssertionError(method)
+
+ def _apply_tap(self, x: int, y: int) -> None:
+ if "快要抢光" in self.hierarchy and "[396,498][895,570]" not in self.hierarchy:
+ self.hierarchy = self.panel_hierarchy
+ return
+ root = ElementTree.fromstring(self.hierarchy)
+ target = next(node for node in root.iter("node") if _center(node.get("bounds", "")) == (x, y))
+ color = target.get("bounds", "").endswith("][438,1172]")
+ for node in root.iter("node"):
+ if node.get("selected") is not None and ((color and ",1000]" in node.get("bounds", "")) or (not color and ",1730]" in node.get("bounds", ""))):
+ node.set("selected", "false")
+ if color and self.fail_color_readback:
+ next(node for node in root.iter("node") if node.get("content-desc") == "粉红").set("selected", "true")
+ else:
+ target.set("selected", "true")
+ self.hierarchy = ElementTree.tostring(root, encoding="unicode")
+
+ def window_size(self) -> tuple[int, int]:
+ self.calls.append(("window_size",))
+ return 1080, 2376
+
+ def select_alternates(self) -> None:
+ root = ElementTree.fromstring(self.panel_hierarchy)
+ for node in root.iter("node"):
+ if node.get("selected") is not None:
+ node.set("selected", "false")
+ next(node for node in root.iter("node") if node.get("content-desc") == "粉红").set("selected", "true")
+ next(node for node in root.iter("node") if node.get("text") == "L(建议115-130)").set("selected", "true")
+ self.panel_hierarchy = ElementTree.tostring(root, encoding="unicode")
+ if self.hierarchy != _PRODUCT_PAGE:
+ self.hierarchy = self.panel_hierarchy
+
+
+def _center(bounds: str) -> tuple[int, int]:
+ left_top, right_bottom = bounds.split("][")
+ left, top = (int(value) for value in left_top.removeprefix("[").split(","))
+ right, bottom = (int(value) for value in right_bottom.removesuffix("]").split(","))
+ return left + (right - left) // 2, top + (bottom - top) // 2
+
+
+def _actions(device: _RawDevice, method: str) -> list[tuple[object, ...]]:
+ return [call for call in device.calls if call[0] == "jsonrpc" and call[1] == method]
+
+
+def _tap_centers(device: _RawDevice) -> list[tuple[int, int]]:
+ return [tuple(call[2]) for call in _actions(device, "click")] # type: ignore[misc]
+
+
+class _FakeAdb:
+ def __init__(self) -> None:
+ self.calls: list[tuple[object, ...]] = []
+ self.inspection = DeviceInspection(AdbDevice(serial="device-1", state="device"), "PKG110", "16")
+ self.on_intent: callable | None = None
+
+ def inspect(self, serial: str) -> DeviceInspection:
+ self.calls.append(("inspect", serial))
+ return self.inspection
+
+ def start_pdd_view_intent(self, serial: str, goods_id: str) -> object:
+ self.calls.append(("intent", serial, goods_id))
+ if self.on_intent is not None:
+ self.on_intent()
+ return object()
+
+
+class SkuSelectionFlowTests(unittest.TestCase):
+ def test_target_mapping_is_exact_and_success_path_restores_target(self) -> None:
+ device = _RawDevice()
+ adapter = UiautomatorSkuPanelAdapter(device, 10)
+ flow = SkuSelectionFlow(adapter)
+
+ flow.open_sku_panel(_TARGET_URL)
+ flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
+ self.assertEqual(flow.read_sku_unit_price(), "12.88")
+ flow.exit_sku_panel_safely()
+
+ self.assertEqual(_tap_centers(device), [(170, 850)])
+ self.assertEqual(_actions(device, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
+
+ def test_unknown_task_or_ui_variants_are_rejected_without_action(self) -> None:
+ for color, size in (("黑色 CHA (纯棉)", _TASK_SIZE), (_TASK_COLOR, "M(建议100-115)"), ("黑色CHA(纯棉)", _TASK_SIZE)):
+ with self.subTest(color=color, size=size), self.assertRaises(SkuSelectionError):
+ resolve_task_selection(color, size)
+
+ device = _RawDevice(_FIXTURE.read_text(encoding="utf-8"))
+ with self.assertRaises(SkuSelectionError):
+ SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).select_sku_options(
+ resolve_task_selection(_TASK_COLOR, _TASK_SIZE).__class__("粉红", "L(建议115-130)")
+ )
+ self.assertEqual(_actions(device, "click"), [])
+
+ def test_option_selected_and_container_drift_fail_closed_before_click(self) -> None:
+ base = _FIXTURE.read_text(encoding="utf-8")
+ cases = (
+ base.replace('selected="true" clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"', 'clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"'),
+ base.replace('selected="true" clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"', 'selected="maybe" clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"'),
+ base.replace('bounds="[126,1000][438,1172]"', 'bounds="[1,1][20,20]"'),
+ base.replace('enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"', 'enabled="false" visible-to-user="true" bounds="[126,1000][438,1172]"'),
+ )
+ for hierarchy in cases:
+ with self.subTest(), self.assertRaises(SkuSelectionError):
+ SkuSelectionFlow(UiautomatorSkuPanelAdapter(_RawDevice(hierarchy), 10)).select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
+
+ def test_invalid_bounds_stop_before_action(self) -> None:
+ for bounds in ("", "[1,2][1,3]", "[1,2][3,2]", "[0,0][1081,1]", "[0,0][1,2377]", "[a,0][1,1]"):
+ with self.subTest(bounds=bounds), self.assertRaises(SkuSelectionError):
+ _action_bounds(bounds)
+
+ device = _RawDevice(_PRODUCT_PAGE.replace("[20,800][320,900]", "[0,0][1081,1]"))
+ with self.assertRaises(SkuSelectionError):
+ SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
+ self.assertEqual(_actions(device, "click"), [])
+
+ def test_color_readback_failure_never_attempts_second_option(self) -> None:
+ device = _RawDevice()
+ device.fail_color_readback = True
+ flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10))
+ flow.open_sku_panel(_TARGET_URL)
+ device.select_alternates()
+ with self.assertRaises(SkuSelectionError):
+ flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
+ self.assertEqual(_tap_centers(device), [(170, 850), (282, 1086)])
+
+ def test_non_target_selection_restores_each_dimension_once(self) -> None:
+ device = _RawDevice()
+ flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10))
+ 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),
+ [(170, 850), (282, 1086), (635, 1772)],
+ )
+
+ def test_price_rejects_coupon_prefix_extra_amount_and_bottom_action(self) -> None:
+ for replacement in ("券后 ¥12.88", "会员补贴 ¥12.88", "到手 ¥12.88", "实付 ¥12.88", "区间 ¥12.88", "原价 ¥12.88", "划线价 ¥12.88", "最低 ¥12.88", "低至 ¥12.88", "起价 ¥12.88", "快卖完 1 ¥12.88", "快卖完 ¥12.88 ¥11.88"):
+ with self.subTest(replacement=replacement):
+ device = _RawDevice(_FIXTURE.read_text(encoding="utf-8").replace("快卖完 ¥12.88", replacement))
+ with self.assertRaises(SkuSelectionError):
+ SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).read_sku_unit_price()
+ device = _RawDevice(_FIXTURE.read_text(encoding="utf-8").replace("快卖完 ¥12.88", "提交订单 ¥12.88"))
+ with self.assertRaises(SkuSelectionError):
+ SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).read_sku_unit_price()
+ clickable_parent = _FIXTURE.read_text(encoding="utf-8").replace(
+ '',
+ '',
+ )
+ with self.assertRaises(SkuSelectionError):
+ SkuSelectionFlow(UiautomatorSkuPanelAdapter(_RawDevice(clickable_parent), 10)).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"}
+ self.assertTrue(forbidden.isdisjoint(SkuSelectionFlow.__dict__))
+ self.assertTrue(forbidden.isdisjoint(SkuPanelDevice.__dict__))
+ self.assertTrue(forbidden.isdisjoint(pdd.__all__))
+
+ def test_static_ast_boundary_limits_flow_runner_adapter_and_cli(self) -> None:
+ root = Path(__file__).resolve().parents[2]
+ files = (
+ root / "src" / "cmbuyer_client" / "pdd" / "sku_selection.py",
+ root / "src" / "cmbuyer_client" / "pdd" / "sku_selection_runner.py",
+ root / "scripts" / "run_t103_sku_selection.py",
+ )
+ forbidden = ("quantity", "confirm", "authorization", "fence", "submit_order", "payment")
+ for path in files:
+ source = path.read_text(encoding="utf-8")
+ with self.subTest(path=path.name):
+ self.assertTrue(all(token not in source.lower() for token in forbidden))
+ tree = ast.parse(source)
+ self.assertFalse(any(isinstance(node, ast.ImportFrom) and node.module in {"selenium", "requests"} for node in ast.walk(tree)))
+ runner_tree = ast.parse(files[1].read_text(encoding="utf-8"))
+ click_calls = [node for node in ast.walk(runner_tree) if isinstance(node, ast.Constant) and node.value == "click"]
+ self.assertEqual(len(click_calls), 1)
+
+ def test_entry_wait_rejects_unchanged_or_duplicate_page_without_click(self) -> None:
+ now = [0.0]
+ device = _RawDevice()
+ flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10), 0.01, 0.01, lambda: now[0], lambda seconds: now.__setitem__(0, now[0] + seconds))
+ with self.assertRaises(SkuSelectionError):
+ flow.open_sku_panel(_TARGET_URL, _PRODUCT_PAGE)
+ self.assertEqual(_actions(device, "click"), [])
+
+ def test_action_postcondition_wait_never_repeats_entry_click(self) -> None:
+ class NoPanelAfterEntry(_RawDevice):
+ def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
+ if method == "click":
+ self.calls.append(("jsonrpc", method, params, timeout))
+ return ""
+ return super().jsonrpc_call(method, params, timeout)
+
+ device = NoPanelAfterEntry()
+ with self.assertRaises(SkuSelectionError):
+ SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
+ self.assertEqual(_tap_centers(device), [(170, 850)])
+
+ duplicate = _PRODUCT_PAGE.replace("", _PRODUCT_PAGE.removeprefix(""))
+ device = _RawDevice(duplicate)
+ with self.assertRaises(SkuSelectionError):
+ SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
+ self.assertEqual(_actions(device, "click"), [])
+
+ def test_fixture_contains_no_address_phone_or_payment_credentials(self) -> None:
+ content = _FIXTURE.read_text(encoding="utf-8")
+ self.assertNotRegex(content, r"1[3-9]\d{9}")
+ for forbidden in ("地址", "收货", "支付", "银行卡", "身份证"):
+ self.assertNotIn(forbidden, content)
+ root = ElementTree.fromstring(content)
+ leaf = next(node for node in root.iter("node") if node.get("text") == "提交订单 ¥12.88")
+ self.assertEqual(leaf.get("clickable"), "false")
+ self.assertEqual(leaf.get("bounds"), "[369,2225][710,2284]")
+
+
+class SkuSelectionRunnerTests(unittest.TestCase):
+ def _runner(self, adb: _FakeAdb, device: _RawDevice) -> SkuSelectionRunner:
+ device.hierarchy = ""
+ adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE.replace("", ''))
+ return SkuSelectionRunner(adb, lambda serial: device, 10)
+
+ def test_runner_atomically_publishes_screenshot_and_redacted_manifest(self) -> None:
+ adb = _FakeAdb()
+ device = _RawDevice()
+ with TemporaryDirectory() as temporary:
+ target = Path(temporary) / "result"
+ result = self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
+
+ self.assertEqual(result.unit_price, "12.88")
+ manifest = result.manifest_path.read_text(encoding="utf-8")
+ 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.assertFalse((target / "hierarchy.xml").exists())
+ self.assertEqual(_actions(device, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
+
+ def test_target_created_during_publish_is_preserved_without_staging_residue(self) -> None:
+ with TemporaryDirectory() as temporary:
+ target = Path(temporary) / "result"
+ original_rename = runner_module.os.rename
+
+ def create_target_then_rename(source: str | Path, destination: str | Path) -> None:
+ Path(destination).mkdir()
+ (Path(destination) / "sentinel").write_text("keep", encoding="utf-8")
+ original_rename(source, destination)
+
+ with patch.object(runner_module.os, "rename", side_effect=create_target_then_rename), self.assertRaises(SkuSelectionRunError):
+ self._runner(_FakeAdb(), _RawDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
+ self.assertEqual((target / "sentinel").read_text(encoding="utf-8"), "keep")
+ self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
+
+ def test_bad_screenshot_or_existing_target_never_publishes_manifest(self) -> None:
+ with TemporaryDirectory() as temporary:
+ target = Path(temporary) / "result"
+ with self.assertRaises(SkuSelectionScreenshotError):
+ self._runner(_FakeAdb(), _RawDevice(screenshot="not-image")).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
+ self.assertFalse(target.exists())
+ self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
+
+ target = Path(temporary) / "write-failure"
+ with patch.object(runner_module, "_save_base64_screenshot", side_effect=OSError("private path")):
+ with self.assertRaises(SkuSelectionScreenshotError):
+ self._runner(_FakeAdb(), _RawDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
+ self.assertFalse(target.exists())
+ self.assertEqual(list(Path(temporary).glob(".write-failure.staging-*")), [])
+
+ adb = _FakeAdb()
+ device = _RawDevice()
+ target.mkdir()
+ sentinel = target / "keep"
+ sentinel.write_text("keep", encoding="utf-8")
+ with self.assertRaises(SkuSelectionRunError):
+ self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
+ self.assertEqual(adb.calls, [])
+ self.assertEqual(device.calls, [])
+ self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
+
+ def test_device_screen_and_output_preflight_fail_before_any_click(self) -> None:
+ with TemporaryDirectory() as temporary:
+ adb = _FakeAdb()
+ adb.inspection = DeviceInspection(AdbDevice(serial="device-1", state="device"), "wrong", "16")
+ device = _RawDevice()
+ with self.assertRaises(SkuSelectionRunError):
+ self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
+ self.assertEqual(device.calls, [])
+
+ class WrongScreenDevice(_RawDevice):
+ def window_size(self) -> tuple[int, int]:
+ return 1080, 1920
+
+ device = WrongScreenDevice()
+ with self.assertRaises(SkuSelectionRunError):
+ self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "screen")
+ self.assertEqual(_actions(device, "click"), [])
+
+ parent_file = Path(temporary) / "not-a-directory"
+ parent_file.write_text("x", encoding="utf-8")
+ adb = _FakeAdb()
+ device = _RawDevice()
+ with self.assertRaises(SkuSelectionRunError):
+ self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, parent_file / "result")
+ self.assertEqual(adb.calls, [])
+ self.assertEqual(device.calls, [])
+
+ def test_small_but_valid_png_is_not_accepted(self) -> None:
+ image = Image.new("RGB", (1, 1), "white")
+ raw = BytesIO(); image.save(raw, format="PNG")
+ with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionScreenshotError):
+ self._runner(_FakeAdb(), _RawDevice(screenshot=base64.b64encode(raw.getvalue()).decode("ascii"))).run(
+ "device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
+ )
+
+ def test_failure_after_entry_attempts_one_safe_exit_and_hides_device_detail(self) -> None:
+ adb = _FakeAdb()
+ device = _RawDevice()
+ device.fail_color_readback = True
+ device.select_alternates()
+ with TemporaryDirectory() as temporary:
+ with self.assertRaises(SkuSelectionError):
+ self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
+ self.assertEqual(_actions(device, "pressKey"), [])
+
+ class FailingRawDevice(_RawDevice):
+ def app_info(self, package_name: str) -> dict[str, str]:
+ raise RuntimeError("device-1 private")
+
+ with self.assertRaises(SkuSelectionDeviceAdapterError) as raised:
+ UiautomatorSkuPanelAdapter(FailingRawDevice(), 10).app_info("com.xunmeng.pinduoduo")
+ self.assertNotIn("device-1", str(raised.exception))
+ self.assertNotIn("private", str(raised.exception))
+
+ def test_unverified_failure_never_sends_blind_back(self) -> None:
+ class InvalidAfterOptionDevice(_RawDevice):
+ def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
+ value = super().jsonrpc_call(method, params, timeout)
+ if method == "click" and "[396,498][895,570]" in self.hierarchy:
+ self.hierarchy = ""
+ return value
+
+ device = InvalidAfterOptionDevice()
+ device.select_alternates()
+ with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError):
+ self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
+ self.assertEqual(_actions(device, "pressKey"), [])
+
+ def test_adapter_timeout_is_mapped_without_third_party_detail(self) -> None:
+ class TimeoutRawDevice(_RawDevice):
+ def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
+ raise TimeoutError("device-1 private")
+
+ with self.assertRaises(SkuSelectionRunError) as raised:
+ UiautomatorSkuPanelAdapter(TimeoutRawDevice(), 10).dump_window_hierarchy()
+ self.assertNotIn("device-1", str(raised.exception))
+ self.assertNotIn("private", str(raised.exception))
+
+ def test_entry_attempt_is_recorded_before_unconfirmed_click_and_not_retried(self) -> None:
+ class TimeoutTapDevice(_RawDevice):
+ def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
+ self.calls.append(("jsonrpc", method, params, timeout))
+ if method == "click":
+ raise TimeoutError("device detail")
+ return super().jsonrpc_call(method, params, timeout)
+
+ adapter = UiautomatorSkuPanelAdapter(TimeoutTapDevice(), 10)
+ with self.assertRaises(SkuSelectionRunError):
+ adapter.tap_sku_entry("[20,800][320,900]")
+ self.assertTrue(adapter.entry_was_tapped)
+ self.assertEqual(_actions(adapter._device, "click"), [("jsonrpc", "click", [170, 850], 10)])
+
+ def test_entry_stability_interruptions_never_click(self) -> None:
+ now = [0.0]
+ class SequenceDevice(_RawDevice):
+ def __init__(self) -> None:
+ super().__init__(); self.frames = [_PRODUCT_PAGE, "", _PRODUCT_PAGE]
+ def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
+ if method == "dumpWindowHierarchy" and self.frames:
+ self.hierarchy = self.frames.pop(0)
+ return super().jsonrpc_call(method, params, timeout)
+ device = SequenceDevice()
+ flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10), .02, .01, lambda: now[0], lambda x: now.__setitem__(0, now[0] + x))
+ with self.assertRaises(SkuSelectionError): flow.open_sku_panel(_TARGET_URL, "")
+ self.assertEqual(_actions(device, "click"), [])
+
+ def test_screenshot_drift_and_foreground_drift_publish_nothing_and_never_back(self) -> None:
+ for drift in ("color", "size", "price"):
+ class DriftDevice(_RawDevice):
+ def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
+ value = super().jsonrpc_call(method, params, timeout)
+ if method == "takeScreenshot":
+ if drift == "price":
+ self.hierarchy = self.hierarchy.replace("快卖完 ¥12.88", "快卖完 ¥13.88")
+ else:
+ root = ElementTree.fromstring(self.hierarchy)
+ if drift == "color":
+ for node in root.iter("node"):
+ if node.get("selected") is not None and ",1000]" in node.get("bounds", ""):
+ node.set("selected", "false")
+ next(node for node in root.iter("node") if node.get("content-desc") == "粉红").set("selected", "true")
+ else:
+ for node in root.iter("node"):
+ if node.get("selected") is not None and ",1730]" in node.get("bounds", ""):
+ node.set("selected", "false")
+ next(node for node in root.iter("node") if node.get("text") == "L(建议115-130)").set("selected", "true")
+ self.hierarchy = ElementTree.tostring(root, encoding="unicode")
+ return value
+ with self.subTest(drift=drift), TemporaryDirectory() as temporary:
+ target = Path(temporary) / "out"
+ with self.assertRaises((SkuSelectionError, SkuSelectionRunError)):
+ self._runner(_FakeAdb(), DriftDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
+ self.assertFalse(target.exists())
+ self.assertFalse((target / "manifest.json").exists())
+ self.assertEqual(list(Path(temporary).glob(".out.staging-*")), [])
+
+ device = _RawDevice(); device.select_alternates()
+ device.package = "other"
+ with self.assertRaises(SkuSelectionError): SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).exit_sku_panel_safely()
+ self.assertEqual(_actions(device, "pressKey"), [])
+
+ def test_screenshot_then_foreground_drift_publishes_nothing_and_never_back(self) -> None:
+ class ForegroundDriftDevice(_RawDevice):
+ def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
+ value = super().jsonrpc_call(method, params, timeout)
+ if method == "takeScreenshot": self.package = "other"
+ return value
+
+ device = ForegroundDriftDevice()
+ with TemporaryDirectory() as temporary:
+ target = Path(temporary) / "out"
+ with self.assertRaises(SkuSelectionError):
+ self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
+ self.assertFalse(target.exists())
+ self.assertFalse((target / "manifest.json").exists())
+ self.assertEqual(list(Path(temporary).glob(".out.staging-*")), [])
+ self.assertEqual(_actions(device, "pressKey"), [])
+
+ def test_option_timeout_reconciliation_controls_back_once(self) -> None:
+ class OptionTimeoutDevice(_RawDevice):
+ def __init__(self, delivered: bool) -> None:
+ super().__init__(); self.delivered = delivered; self.clicks = 0
+ def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
+ if method == "click":
+ self.clicks += 1
+ if self.clicks == 2:
+ if self.delivered: super().jsonrpc_call(method, params, timeout)
+ else: self.calls.append(("jsonrpc", method, params, timeout))
+ raise TimeoutError("uncertain option")
+ return super().jsonrpc_call(method, params, timeout)
+
+ for delivered, expected_back in ((False, 0), (True, 1)):
+ with self.subTest(delivered=delivered), TemporaryDirectory() as temporary:
+ device = OptionTimeoutDevice(delivered); device.select_alternates()
+ adb = _FakeAdb(); device.hierarchy = ""
+ adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE.replace("", ''))
+ runner = SkuSelectionRunner(adb, lambda serial: device, .03)
+ with self.assertRaises(SkuSelectionRunError):
+ runner.run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "out")
+ self.assertEqual(len(_actions(device, "click")), 2)
+ self.assertEqual(len(_actions(device, "pressKey")), expected_back)
+
+ def test_back_timeout_is_never_retried(self) -> None:
+ class BackTimeoutDevice(_RawDevice):
+ def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
+ if method == "pressKey":
+ super().jsonrpc_call(method, params, timeout)
+ raise TimeoutError("back uncertain")
+ return super().jsonrpc_call(method, params, timeout)
+
+ device = BackTimeoutDevice()
+ with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionRunError):
+ self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "out")
+ self.assertEqual(len(_actions(device, "pressKey")), 1)
+
+ def test_entry_click_timeout_reconciles_only_through_verified_flow_exit(self) -> None:
+ class DeliveredThenTimeout(_RawDevice):
+ def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
+ if method == "click" and self.hierarchy != _FIXTURE.read_text(encoding="utf-8"):
+ super().jsonrpc_call(method, params, timeout)
+ raise TimeoutError("delivery uncertain")
+ return super().jsonrpc_call(method, params, timeout)
+
+ device = DeliveredThenTimeout()
+ with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionRunError):
+ self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
+ self.assertEqual(len(_actions(device, "click")), 1)
+ self.assertEqual(len(_actions(device, "pressKey")), 1)
+
+
+class SkuSelectionCliTests(unittest.TestCase):
+ def test_cli_accepts_only_target_url_and_task_values(self) -> None:
+ script = _load_runner_script()
+ valid = {
+ "serial": "device-1",
+ "url": _TARGET_URL,
+ "color": _TASK_COLOR,
+ "size": _TASK_SIZE,
+ "output_dir": Path("evidence"),
+ "timeout": 10.0,
+ "adb": "adb",
+ }
+ script.validate_arguments(type("Arguments", (), valid)())
+ for field, value in (("serial", ""), ("url", "https://mobile.yangkeduo.com/goods.html?goods_id=1"), ("color", "黑色 CHA (纯棉)"), ("size", "M(建议100-115)"), ("timeout", 0), ("timeout", float("inf"))):
+ with self.subTest(field=field, value=value), self.assertRaises((ValueError, SkuSelectionError)):
+ script.validate_arguments(type("Arguments", (), valid | {field: value})())
+
+ def test_cli_main_catches_flow_error_without_traceback_or_page_body(self) -> None:
+ script = _load_runner_script()
+
+ class FlowFailingRunner:
+ def __init__(self, *args: object, **kwargs: object) -> None: pass
+ def run(self, *args: object, **kwargs: object) -> object:
+ raise SkuSelectionError("page-body")
+
+ stderr = BytesIO()
+ # TextIOWrapper keeps the assertion independent from host console encoding.
+ import io
+ text_stderr = io.TextIOWrapper(stderr, encoding="utf-8")
+ with patch.object(script, "SkuSelectionRunner", FlowFailingRunner), redirect_stderr(text_stderr):
+ status = script.main([
+ "--serial", "device-1", "--url", _TARGET_URL, "--color", _TASK_COLOR,
+ "--size", _TASK_SIZE, "--output-dir", "evidence",
+ ])
+ text_stderr.flush()
+ output = stderr.getvalue().decode("utf-8")
+ self.assertEqual(status, 1)
+ self.assertNotIn("Traceback", output)
+ self.assertNotIn("page-body", output)
+
+
+def _load_runner_script() -> object:
+ path = Path(__file__).resolve().parents[2] / "scripts" / "run_t103_sku_selection.py"
+ specification = importlib.util.spec_from_file_location("run_t103_sku_selection_test", path)
+ if specification is None or specification.loader is None:
+ raise RuntimeError("无法加载 T-103 运行脚本。")
+ module = importlib.util.module_from_spec(specification)
+ specification.loader.exec_module(module)
+ return module
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/docs/tasks/T-103.md b/docs/tasks/T-103.md
index 48e4fd3..c548309 100644
--- a/docs/tasks/T-103.md
+++ b/docs/tasks/T-103.md
@@ -17,6 +17,7 @@ write_paths:
- client/tests/pdd/**
- client/tests/device/**
- client/scripts/capture_sku_panel_spike.py
+ - client/scripts/run_t103_sku_selection.py
- client/scripts/sanitize_sku_panel_evidence.py
- docs/02-requirements.md
- docs/03-tech-stack.md