fix(client): accept exact current-only sku state

This commit is contained in:
QiuSW
2026-08-05 15:20:09 +08:00
parent 5a4633fb64
commit e1e534c5d3
8 changed files with 821 additions and 58 deletions
+20 -4
View File
@@ -13,9 +13,17 @@ 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.sku_reveal_spike import SkuRevealSpikeCapturer, SkuRevealSpikeError
from cmbuyer_client.pdd.sku_selection import EXPECTED_GOODS_ID, SkuSelectionError
from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError, safe_failure_stage
from cmbuyer_client.pdd.sku_reveal_spike import (
safe_reveal_failure_stage,
SkuRevealSpikeCapturer,
SkuRevealSpikeError,
)
from cmbuyer_client.pdd.sku_selection import (
EXPECTED_GOODS_ID,
_safe_sku_entry_failure_stage,
SkuSelectionError,
)
from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
@@ -42,6 +50,14 @@ def validate_arguments(arguments: argparse.Namespace) -> None:
raise ValueError("--timeout 必须是大于 0 的有限数值。")
def _safe_capture_failure_stage(error: BaseException) -> str:
# 入口与 reveal 各有不可伪造的正式 marker;入口优先,不能被外层 reveal 标注覆盖。
entry_stage = _safe_sku_entry_failure_stage(error)
if entry_stage is not None:
return entry_stage
return safe_reveal_failure_stage(error) or "unknown"
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
@@ -74,7 +90,7 @@ def main(argv: list[str] | None = None) -> int:
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError, SkuRevealSpikeError) as error:
# 不回显页面正文、节点、serial、坐标、路径或第三方异常。
print(
f"规格 reveal 取证失败:stage={safe_failure_stage(error)};已停止,未发布本地证据目录。",
f"规格 reveal 取证失败:stage={_safe_capture_failure_stage(error)};已停止,未发布本地证据目录。",
file=sys.stderr,
)
return 1
@@ -66,12 +66,48 @@ _TARGET_URL = f"https://mobile.yangkeduo.com/goods.html?goods_id={EXPECTED_GOODS
_REVEAL_START = (360, 1900)
_REVEAL_END = (360, 1300)
_REVEAL_STEPS = 30
_REVEAL_FAILURE_STAGES = frozenset(
(
"reveal_precondition",
"reveal_attempted",
"reveal_candidate",
"reveal_after",
"reveal_publish",
)
)
_REVEAL_FAILURE_MARKER = object()
class SkuRevealSpikeError(RuntimeError):
"""一次性 reveal 取证未形成可发布证据。"""
def _annotate_reveal_failure(error: BaseException, stage: str) -> None:
"""只记录本模块实际走到的固定阶段;入口阶段使用独立 marker,不会被覆盖。"""
if type(stage) is not str or stage not in _REVEAL_FAILURE_STAGES:
return
try:
setattr(error, "_cmbuyer_reveal_failure_stage", stage)
# marker 最后写入,任一 setter 失败都不能形成可信阶段。
setattr(error, "_cmbuyer_reveal_failure_marker", _REVEAL_FAILURE_MARKER)
except BaseException:
pass
def safe_reveal_failure_stage(error: BaseException) -> str | None:
"""读取可公开的 reveal 控制流阶段;伪造属性或 hostile getter 均失败闭合。"""
try:
marker = getattr(error, "_cmbuyer_reveal_failure_marker", None)
stage = getattr(error, "_cmbuyer_reveal_failure_stage", None)
if marker is not _REVEAL_FAILURE_MARKER or type(stage) is not str:
return None
return stage if stage in _REVEAL_FAILURE_STAGES else None
except BaseException:
return None
@dataclass(frozen=True)
class SkuRevealSpikeResult:
output_directory: Path
@@ -127,13 +163,15 @@ class SkuRevealSpikeCapturer:
goods_id: str,
output_directory: Path,
) -> SkuRevealSpikeResult:
stage = "reveal_precondition"
staging: Path | None = None
adapter: _RevealEvidenceAdapter | None = None
try:
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
raise SkuRevealSpikeError("商品不是 T-103 已取证目标,已停止取证。")
link = parse_product_url(_TARGET_URL)
target = Path(output_directory)
_validate_new_target(target)
staging: Path | None = None
try:
staging = _prepare_staging(target)
deadline = self._clock() + self._timeout_seconds
inspection = self._adb_client.inspect(serial)
@@ -180,12 +218,16 @@ class SkuRevealSpikeCapturer:
(staging / "before" / "hierarchy.xml").write_text(before_hierarchy, encoding="utf-8")
rpc_outcome = "completed"
# 此后即属于 attempted:adapter 会在 RPC 前封存唯一机会,结果不明也只能调和。
stage = "reveal_attempted"
try:
adapter.reveal_size_options_once()
except SkuSelectionRunError:
rpc_outcome = "ambiguous_reconciled"
stage = "reveal_candidate"
projection, after_hierarchy = self._wait_for_candidate(adapter, deadline)
stage = "reveal_after"
after_directory = staging / "after"
_capture_frame(adapter, after_directory, after_hierarchy)
reverified = adapter.dump_window_hierarchy()
@@ -193,6 +235,7 @@ class SkuRevealSpikeCapturer:
raise SkuRevealSpikeError("截图后候选状态漂移,未发布证据。")
(after_directory / "hierarchy.xml").write_text(reverified, encoding="utf-8")
stage = "reveal_publish"
manifest = _manifest(inspection, serial, rpc_outcome, staging)
manifest_path = staging / "manifest.json"
manifest_path.write_text(
@@ -201,12 +244,22 @@ class SkuRevealSpikeCapturer:
)
os.rename(staging, target)
staging = None
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError, SkuRevealSpikeError):
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError, SkuRevealSpikeError) as error:
_clean_staging(staging)
failure_stage = stage
if stage == "reveal_attempted" and (adapter is None or not adapter.reveal_attempted):
# attempted 只能在 adapter 已于 RPC 前封存唯一机会后成立。
failure_stage = "reveal_precondition"
_annotate_reveal_failure(error, failure_stage)
raise
except Exception as error:
_clean_staging(staging)
raise SkuRevealSpikeError("规格 reveal 取证未完成,未发布本地证据目录。") from error
mapped = SkuRevealSpikeError("规格 reveal 取证未完成,未发布本地证据目录。")
failure_stage = stage
if stage == "reveal_attempted" and (adapter is None or not adapter.reveal_attempted):
failure_stage = "reveal_precondition"
_annotate_reveal_failure(mapped, failure_stage)
raise mapped from error
return SkuRevealSpikeResult(target, target / "manifest.json")
+199 -34
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from functools import partial
import re
from time import monotonic, sleep
from typing import Any, Callable, Protocol
@@ -126,15 +127,32 @@ class _PanelProfile(Enum):
@dataclass(frozen=True)
class _PanelSpec:
profile: _PanelProfile
header_bounds: str
outer_bounds: str
price_row_bounds: str
class _DualPriceLayout:
row_bounds: str
current_text: str
current_bounds: str
original_text: str
original_bounds: str
@dataclass(frozen=True)
class _CurrentOnlyPriceLayout:
# 同一 App 版本会把原价整段移除;这是独立证据布局,不是“原价可选”。
frame_bounds: str
row_bounds: str
current_text: str
current_bounds: str
_PriceLayout = _DualPriceLayout | _CurrentOnlyPriceLayout
@dataclass(frozen=True)
class _PanelSpec:
profile: _PanelProfile
header_bounds: str
outer_bounds: str
price_layout: _PriceLayout
summary_text: str
summary_bounds: str
color_label_bounds: str | None
@@ -149,8 +167,10 @@ _PANEL_SPECS = (
_PanelSpec(
_PanelProfile.PANEL_OPEN_EMPTY,
"[0,366][1080,1077]", "[0,1077][1080,2079]",
_DualPriceLayout(
"[396,575][912,647]", "限1件 ¥12.88 ", "[396,580][675,647]",
"券前¥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,2069][114,2079]", None,
@@ -158,8 +178,32 @@ _PANEL_SPECS = (
_PanelSpec(
_PanelProfile.COLOR_SELECTED_SIZE_HIDDEN,
"[0,366][1080,1077]", "[0,1077][1080,2079]",
_DualPriceLayout(
"[396,575][912,647]", "限1件 ¥12.88 ", "[396,580][675,647]",
"券前¥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,2069][114,2079]", None,
),
_PanelSpec(
_PanelProfile.PANEL_OPEN_EMPTY,
"[0,366][1080,1077]", "[0,1077][1080,2079]",
_CurrentOnlyPriceLayout(
"[396,575][1053,647]", "[396,575][693,647]",
"限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,2069][114,2079]", None,
),
_PanelSpec(
_PanelProfile.COLOR_SELECTED_SIZE_HIDDEN,
"[0,366][1080,1077]", "[0,1077][1080,2079]",
_CurrentOnlyPriceLayout(
"[396,575][1053,647]", "[396,575][693,647]",
"限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,2069][114,2079]", None,
@@ -167,8 +211,10 @@ _PANEL_SPECS = (
_PanelSpec(
_PanelProfile.SIZE_VISIBLE_NON_TARGET,
"[0,366][1080,1000]", "[0,1000][1080,2079]",
_DualPriceLayout(
"[396,498][895,570]", _ROLLED_CURRENT_PRICE, "[396,503][712,570]",
_ROLLED_ORIGINAL_PRICE, "[730,503][895,570]",
),
_S_SUMMARY, "[396,654][1053,716]", None,
"[36,1000][1080,1483]", "[372,1000][684,1024]", True,
"[36,1506][114,1552]", _S_SIZE_UI,
@@ -176,8 +222,10 @@ _PANEL_SPECS = (
_PanelSpec(
_PanelProfile.TARGETS_SELECTED,
"[0,366][1080,1000]", "[0,1000][1080,2079]",
_DualPriceLayout(
"[396,498][895,570]", _ROLLED_CURRENT_PRICE, "[396,503][712,570]",
_ROLLED_ORIGINAL_PRICE, "[730,503][895,570]",
),
_TARGET_SUMMARY, "[396,654][1053,716]", None,
"[36,1000][1080,1483]", "[372,1000][684,1024]", True,
"[36,1506][114,1552]", _TARGET_SIZE_UI,
@@ -235,15 +283,21 @@ class SkuSelectionFlow:
self._require_foreground()
before = self._read_hierarchy()
nodes = _parse_nodes(before)
profile = _classify_panel(nodes)
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)
self._pending = (before, _require_color_only_panel)
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, _require_color_only_panel)
self._wait_after_action(before, color_postcondition)
# 当前证据只证明颜色选择;尺码仍在视口外。没有动作证据时必须在此停住,
# 不能把一次通用 swipe 或下一次规格点击伪装成已验证流程。
raise SkuSelectionError(_REVEAL_NOT_PROVEN)
@@ -383,14 +437,14 @@ def _parse_nodes(raw: str) -> list[_Node]:
return result
def _classify_panel(nodes: list[_Node]) -> _PanelProfile:
matches: list[_PanelProfile] = []
def _classify_panel(nodes: list[_Node]) -> _PanelSpec:
matches: list[_PanelSpec] = []
for spec in _PANEL_SPECS:
try:
_match_panel_profile(nodes, spec)
except SkuSelectionError:
continue
matches.append(spec.profile)
matches.append(spec)
if len(matches) != 1:
raise SkuSelectionError("规格面板不符合唯一完整取证 profile,已停止操作。")
return matches[0]
@@ -408,9 +462,23 @@ def _require_target_panel(nodes: list[_Node]) -> None:
_require_profile(nodes, _PanelProfile.TARGETS_SELECTED)
def _require_profile(nodes: list[_Node], expected: _PanelProfile) -> None:
if _classify_panel(nodes) is not expected:
def _require_profile(nodes: list[_Node], expected: _PanelProfile) -> _PanelSpec:
spec = _classify_panel(nodes)
if spec.profile is not expected:
raise SkuSelectionError("规格面板动作后状态与已取证 profile 不一致,已停止操作。")
return spec
def _require_profile_with_price_layout(
nodes: list[_Node],
*,
expected: _PanelProfile,
expected_price_layout: _PriceLayout,
) -> None:
spec = _require_profile(nodes, expected)
# 同一次颜色点击不得跨价格证据 variant;完整 dataclass identity 同时绑定类型与全部 exact 字段。
if spec.price_layout != expected_price_layout:
raise SkuSelectionError("规格面板动作后价格布局发生跨变体漂移,已停止操作。")
def _match_panel_profile(nodes: list[_Node], spec: _PanelSpec) -> None:
@@ -427,24 +495,24 @@ def _match_panel_profile(nodes: list[_Node], spec: _PanelSpec) -> None:
[node for node in nodes if node.parent is surface and _exact_recycler(node, spec.outer_bounds)],
"规格面板维度容器不唯一。",
)
price_row = _one(
[node for node in nodes if _descendant(node, header) and _exact_inert(node, "android.widget.LinearLayout", spec.price_row_bounds)],
"规格面板价格行不唯一。",
)
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, spec.current_text, spec.current_bounds)],
"规格面板当前价角色不唯一。",
)
_one(
[node for node in nodes if node.parent is price_row and _exact_readonly_text(node, spec.original_text, spec.original_bounds)],
"规格面板原价角色不唯一。",
current, summary_anchor, summary_is_direct = _match_price_layout(
nodes,
header,
spec.price_layout,
)
if _clickable_before(current, surface):
raise SkuSelectionError("规格面板价格角色位于可点击内容祖先下。")
_one(
[node for node in nodes if _descendant(node, header) and _exact_readonly_text(node, spec.summary_text, spec.summary_bounds)],
[
node
for node in nodes
if (
node.parent is summary_anchor
if summary_is_direct
else _descendant(node, summary_anchor)
)
and _exact_readonly_text(node, spec.summary_text, spec.summary_bounds)
],
"规格面板摘要不唯一。",
)
@@ -507,11 +575,13 @@ def _match_panel_profile(nodes: list[_Node], spec: _PanelSpec) -> None:
def _unit_price(nodes: list[_Node]) -> str:
_require_profile(nodes, _PanelProfile.TARGETS_SELECTED)
spec = _spec_for(_PanelProfile.TARGETS_SELECTED)
spec = _classify_panel(nodes)
if spec.profile is not _PanelProfile.TARGETS_SELECTED:
raise SkuSelectionError("规格面板动作后状态与已取证 profile 不一致,已停止操作。")
current_bounds = spec.price_layout.current_bounds
candidates = [
node for node in nodes
if _exact_readonly_text(node, _ROLLED_CURRENT_PRICE, spec.current_bounds)
if _exact_readonly_text(node, _ROLLED_CURRENT_PRICE, current_bounds)
]
current = _one(candidates, "规格面板现价不唯一,已停止读取。")
surface = _one([node for node in nodes if _exact_inert(node, "android.view.ViewGroup", _PANEL_SURFACE)], "规格面板内容面不唯一。")
@@ -521,16 +591,16 @@ def _unit_price(nodes: list[_Node]) -> str:
def _target_color_action(nodes: list[_Node], *, selected: bool) -> _Node:
profile = _classify_panel(nodes)
spec = _classify_panel(nodes)
profile = spec.profile
expected_profile = _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN if selected else _PanelProfile.PANEL_OPEN_EMPTY
if profile is not expected_profile:
raise SkuSelectionError("目标颜色 action 不属于预期 profile。")
spec = _spec_for(profile)
return _one([node for node in nodes if _exact_color_action(node, spec.color_bounds, selected)], "目标颜色 action 不唯一。")
def _target_size_action(nodes: list[_Node], *, selected: bool) -> _Node:
profile = _classify_panel(nodes)
profile = _classify_panel(nodes).profile
expected_profile = _PanelProfile.TARGETS_SELECTED if selected else _PanelProfile.SIZE_VISIBLE_NON_TARGET
if profile is not expected_profile:
raise SkuSelectionError("目标尺码 action 不属于预期 profile。")
@@ -541,7 +611,102 @@ def _target_size_action(nodes: list[_Node], *, selected: bool) -> _Node:
def _spec_for(profile: _PanelProfile) -> _PanelSpec:
return next(spec for spec in _PANEL_SPECS if spec.profile is profile)
matches = [spec for spec in _PANEL_SPECS if spec.profile is profile]
if len(matches) != 1:
raise SkuSelectionError("该 profile 没有唯一静态 spec。")
return matches[0]
def _match_price_layout(
nodes: list[_Node],
header: _Node,
layout: _PriceLayout,
) -> tuple[_Node, _Node, bool]:
if isinstance(layout, _DualPriceLayout):
price_row = _one(
[
node
for node in nodes
if _descendant(node, header)
and _exact_inert(node, "android.widget.LinearLayout", layout.row_bounds)
],
"规格面板双价格行不唯一。",
)
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, layout.current_text, layout.current_bounds)
],
"规格面板双价格当前价角色不唯一。",
)
_one(
[
node
for node in nodes
if node.parent is price_row
and _exact_readonly_text(node, layout.original_text, layout.original_bounds)
],
"规格面板双价格原价角色不唯一。",
)
# 历史已批准 raw 的双价格行和摘要位于 header 内的嵌套内容容器,
# 旧证据只允许后代关系,不能套用 current-only 的直接父链。
return current, header, False
frame = _one(
[
node
for node in nodes
if _descendant(node, header)
and _exact_inert(node, "android.widget.FrameLayout", layout.frame_bounds)
],
"规格面板单价格外层不唯一。",
)
if len([child for child in frame.element if child.tag == "node"]) != 1:
raise SkuSelectionError("规格面板单价格外层子节点数量漂移。")
content = frame.parent
relative = content.parent if content is not None else None
content_frame = relative.parent if relative is not None else None
if (
content is None
or relative is None
or content_frame is None
or content_frame.parent is not header
or not _exact_inert(content, "android.view.ViewGroup", "[0,551][1080,940]")
or not _exact_inert(relative, "android.widget.RelativeLayout", "[0,551][1080,940]")
or not _exact_inert(content_frame, "android.widget.FrameLayout", "[0,551][1080,940]")
):
raise SkuSelectionError("规格面板单价格外层父链漂移。")
price_row = _one(
[
node
for node in nodes
if node.parent is frame
and _exact_inert(node, "android.widget.LinearLayout", layout.row_bounds)
],
"规格面板单价格行父子关系漂移。",
)
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, layout.current_text, layout.current_bounds)
],
"规格面板单价格当前价角色不唯一。",
)
if any(
_descendant(node, header)
and node.text == "券前¥29.88"
for node in nodes
):
raise SkuSelectionError("规格面板单价格变体出现原价角色。")
return current, content, True
def _exact_inert(node: _Node, class_name: str, bounds: str) -> bool:
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1077]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.RelativeLayout" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[396,575][1053,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,575][693,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="限1件 ¥12.88 " package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,580][675,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
<node text="请选择: 尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,731][1053,793]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1077][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,1188]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="颜色分类" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1106][192,1159]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1188][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1188][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1188][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][684,1500]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
<node content-desc="打开大图" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][483,1299]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1479][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1479][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,2052][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,2052][1044,2079]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,2069][114,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1077]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.RelativeLayout" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[396,575][1053,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,575][693,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="限1件 ¥12.88 " package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,580][675,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
<node text="请选择: 颜色分类 尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,731][1053,793]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1077][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,1188]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="颜色分类" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1106][192,1159]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1188][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1188][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1188][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][684,1500]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node content-desc="打开大图" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][483,1299]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1479][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1479][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,2052][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,2052][1044,2079]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,2069][114,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>
+184 -3
View File
@@ -14,8 +14,12 @@ from xml.etree import ElementTree
from PIL import Image
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,
_RevealEvidenceAdapter,
safe_reveal_failure_stage,
SkuRevealSpikeCapturer,
SkuRevealSpikeError,
_require_safe_reveal_path,
@@ -31,6 +35,8 @@ _FIXTURES = Path(__file__).with_name("fixtures")
_PRODUCT = (_FIXTURES / "product_entry_8_17_0.xml").read_text(encoding="utf-8")
_EMPTY = (_FIXTURES / "sku_panel_empty_8_17_0.xml").read_text(encoding="utf-8")
_COLOR_ONLY = (_FIXTURES / "sku_panel_color_selected_size_hidden_8_17_0.xml").read_text(encoding="utf-8")
_CURRENT_ONLY_EMPTY = (_FIXTURES / "sku_panel_empty_current_only_8_17_0.xml").read_text(encoding="utf-8")
_CURRENT_ONLY_COLOR = (_FIXTURES / "sku_panel_color_selected_size_hidden_current_only_8_17_0.xml").read_text(encoding="utf-8")
_M_SELECTED = (_FIXTURES / "sku_panel_size_m_restored_8_17_0.xml").read_text(encoding="utf-8")
@@ -171,6 +177,8 @@ def _png() -> str:
class _FakeDevice:
def __init__(self) -> None:
self.hierarchy = "<hierarchy />"
self.empty_hierarchy = _EMPTY
self.color_hierarchy = _COLOR_ONLY
self.after_hierarchy = _candidate_unselected()
self.calls: list[tuple[object, ...]] = []
self.swipe_error = False
@@ -195,10 +203,10 @@ class _FakeDevice:
return _png()
if method == "click":
if params == [865, 2218]:
self.hierarchy = _EMPTY
self.hierarchy = self.empty_hierarchy
return ""
if params == [528, 1387]:
self.hierarchy = _COLOR_ONLY
self.hierarchy = self.color_hierarchy
return ""
raise AssertionError(params)
if method == "swipe":
@@ -282,6 +290,50 @@ class SkuRevealSpikeTests(unittest.TestCase):
self.assertNotIn("start", manifest)
self.assertNotIn("end", manifest)
def test_current_only_empty_and_color_only_are_accepted_before_reveal(self) -> None:
device = _FakeDevice()
device.empty_hierarchy = _CURRENT_ONLY_EMPTY
device.color_hierarchy = _CURRENT_ONLY_COLOR
with TemporaryDirectory() as directory:
target = Path(directory) / "evidence"
result = self._capturer(device).capture(
"192.168.0.173:5555",
"937122477375",
target,
)
self.assertEqual(result.output_directory, target)
self.assertEqual(len(_swipes(device)), 1)
self.assertEqual(
(target / "before" / "hierarchy.xml").read_text(encoding="utf-8"),
_CURRENT_ONLY_COLOR,
)
def test_cross_price_variant_after_color_click_is_precondition_failure_without_reveal(self) -> None:
for empty_hierarchy, color_hierarchy in (
(_CURRENT_ONLY_EMPTY, _COLOR_ONLY),
(_EMPTY, _CURRENT_ONLY_COLOR),
):
with self.subTest():
device = _FakeDevice()
device.empty_hierarchy = empty_hierarchy
device.color_hierarchy = color_hierarchy
with TemporaryDirectory() as directory:
target = Path(directory) / "evidence"
with self.assertRaises(SkuSelectionError) as raised:
self._capturer(device).capture(
"192.168.0.173:5555",
"937122477375",
target,
)
self.assertEqual(
safe_reveal_failure_stage(raised.exception),
"reveal_precondition",
)
self.assertEqual(_swipes(device), [])
self.assertFalse(target.exists())
self.assertEqual(list(Path(directory).glob(".*.staging-*")), [])
def test_ambiguous_rpc_is_read_only_reconciled_without_retry(self) -> None:
device = _FakeDevice()
device.swipe_error = True
@@ -367,7 +419,7 @@ class SkuRevealSpikeTests(unittest.TestCase):
device.jsonrpc_call = drift # type: ignore[method-assign]
with TemporaryDirectory() as directory:
target = Path(directory) / "evidence"
with self.assertRaises(SkuSelectionError):
with self.assertRaises(SkuSelectionError) as raised:
self._capturer(device).capture(
"192.168.0.173:5555",
"937122477375",
@@ -375,6 +427,89 @@ class SkuRevealSpikeTests(unittest.TestCase):
)
self.assertFalse(target.exists())
self.assertEqual(_swipes(device), [])
self.assertEqual(safe_reveal_failure_stage(raised.exception), "reveal_precondition")
def test_reveal_failure_stages_bind_attempt_candidate_after_and_publish(self) -> None:
device = _FakeDevice()
def fail_before_seal(adapter: _RevealEvidenceAdapter) -> None:
raise SkuRevealSpikeError("private pre-seal detail")
with TemporaryDirectory() as directory:
target = Path(directory) / "pre-seal"
with (
patch.object(_RevealEvidenceAdapter, "reveal_size_options_once", fail_before_seal),
self.assertRaises(SkuRevealSpikeError) as pre_seal,
):
self._capturer(device).capture("192.168.0.173:5555", "937122477375", target)
self.assertEqual(safe_reveal_failure_stage(pre_seal.exception), "reveal_precondition")
self.assertEqual(_swipes(device), [])
self.assertFalse(target.exists())
self.assertEqual(list(Path(directory).glob(".*.staging-*")), [])
# attempted 只可能在 adapter 已封存并发送唯一手势后报告。
device = _FakeDevice()
original_reveal = _RevealEvidenceAdapter.reveal_size_options_once
def fail_after_attempt(adapter: _RevealEvidenceAdapter) -> None:
original_reveal(adapter)
raise SkuRevealSpikeError("private attempted detail")
with TemporaryDirectory() as directory:
target = Path(directory) / "attempted"
with (
patch.object(_RevealEvidenceAdapter, "reveal_size_options_once", fail_after_attempt),
self.assertRaises(SkuRevealSpikeError) as attempted,
):
self._capturer(device).capture("192.168.0.173:5555", "937122477375", target)
self.assertEqual(safe_reveal_failure_stage(attempted.exception), "reveal_attempted")
self.assertEqual(len(_swipes(device)), 1)
self.assertFalse(target.exists())
self.assertEqual(list(Path(directory).glob(".*.staging-*")), [])
device = _FakeDevice()
device.after_hierarchy = _COLOR_ONLY
with TemporaryDirectory() as directory:
target = Path(directory) / "candidate"
with self.assertRaises(SkuRevealSpikeError) as candidate:
self._capturer(device).capture("192.168.0.173:5555", "937122477375", target)
self.assertEqual(safe_reveal_failure_stage(candidate.exception), "reveal_candidate")
self.assertEqual(len(_swipes(device)), 1)
self.assertFalse(target.exists())
self.assertEqual(list(Path(directory).glob(".*.staging-*")), [])
device = _FakeDevice()
original_capture = reveal_module._capture_frame
def fail_after_capture(adapter: object, directory: Path, hierarchy: str) -> None:
if directory.name == "after":
raise OSError("private after path")
original_capture(adapter, directory, hierarchy)
with TemporaryDirectory() as directory:
target = Path(directory) / "after"
with (
patch.object(reveal_module, "_capture_frame", fail_after_capture),
self.assertRaises(SkuRevealSpikeError) as after,
):
self._capturer(device).capture("192.168.0.173:5555", "937122477375", target)
self.assertEqual(safe_reveal_failure_stage(after.exception), "reveal_after")
self.assertEqual(len(_swipes(device)), 1)
self.assertFalse(target.exists())
self.assertEqual(list(Path(directory).glob(".*.staging-*")), [])
device = _FakeDevice()
with TemporaryDirectory() as directory:
target = Path(directory) / "publish"
with (
patch.object(reveal_module.os, "rename", side_effect=OSError("private publish path")),
self.assertRaises(SkuRevealSpikeError) as publish,
):
self._capturer(device).capture("192.168.0.173:5555", "937122477375", target)
self.assertEqual(safe_reveal_failure_stage(publish.exception), "reveal_publish")
self.assertEqual(len(_swipes(device)), 1)
self.assertFalse(target.exists())
self.assertEqual(list(Path(directory).glob(".*.staging-*")), [])
def test_complete_reveal_segment_rejects_narrow_impostor_and_invalid_bounds(self) -> None:
for hierarchy in (
@@ -481,12 +616,58 @@ class SkuRevealSpikeCliTests(unittest.TestCase):
annotated = SkuSelectionError(secret)
_annotate_sku_entry_failure(annotated, "sku_entry_panel_verify")
_annotate_reveal_failure(annotated, "reveal_precondition")
self.assertIn("stage=sku_entry_panel_verify", run_with(annotated))
spoofed = SkuSelectionError(secret)
setattr(spoofed, "_cmbuyer_failure_stage", "sku_entry_panel_verify")
self.assertIn("stage=unknown", run_with(spoofed))
def test_cli_reports_only_formally_annotated_reveal_stage(self) -> None:
script = _load_reveal_script()
secret = "SERIAL=192.168.0.173:5555 <hierarchy>private</hierarchy>"
def run_with(error: BaseException) -> str:
class FailingCapturer:
def __init__(self, *args: object, **kwargs: object) -> None:
return None
def capture(self, *args: object, **kwargs: object) -> object:
raise error
stderr = io.StringIO()
with patch.object(script, "SkuRevealSpikeCapturer", FailingCapturer), redirect_stderr(stderr):
self.assertEqual(
script.main(
[
"--serial", "192.168.0.173:5555",
"--goods-id", "937122477375",
"--output-dir", "evidence",
]
),
1,
)
output = stderr.getvalue()
self.assertNotIn("192.168.0.173:5555", output)
self.assertNotIn("private", output)
return output
annotated = SkuRevealSpikeError(secret)
_annotate_reveal_failure(annotated, "reveal_candidate")
self.assertIn("stage=reveal_candidate", run_with(annotated))
spoofed = SkuRevealSpikeError(secret)
setattr(spoofed, "_cmbuyer_reveal_failure_stage", "reveal_candidate")
self.assertIn("stage=unknown", run_with(spoofed))
class HostileGetterError(SkuRevealSpikeError):
def __getattribute__(self, name: str) -> object:
if name.startswith("_cmbuyer_reveal_"):
raise RuntimeError(secret)
return super().__getattribute__(name)
self.assertIn("stage=unknown", run_with(HostileGetterError(secret)))
def _load_reveal_script() -> object:
path = Path(__file__).resolve().parents[2] / "scripts" / "capture_sku_reveal_spike.py"
+227 -1
View File
@@ -18,6 +18,7 @@ import cmbuyer_client.pdd.sku_selection_runner as runner_module
from cmbuyer_client.device.adb import AdbDevice, DeviceConnectionError, DeviceInspection
from cmbuyer_client.pdd import SkuSelectionError, SkuSelectionFlow, SkuSelectionRunner
from cmbuyer_client.pdd.sku_selection import (
_PanelProfile,
SkuPanelDevice,
_action_bounds,
_classify_panel,
@@ -36,6 +37,8 @@ from cmbuyer_client.pdd.sku_selection_runner import (
_FIXTURES = Path(__file__).with_name("fixtures")
_EMPTY_FIXTURE = _FIXTURES / "sku_panel_empty_8_17_0.xml"
_COLOR_FIXTURE = _FIXTURES / "sku_panel_color_selected_size_hidden_8_17_0.xml"
_CURRENT_ONLY_EMPTY_FIXTURE = _FIXTURES / "sku_panel_empty_current_only_8_17_0.xml"
_CURRENT_ONLY_COLOR_FIXTURE = _FIXTURES / "sku_panel_color_selected_size_hidden_current_only_8_17_0.xml"
_S_FIXTURE = _FIXTURES / "sku_panel_size_s_selected_8_17_0.xml"
_M_FIXTURE = _FIXTURES / "sku_panel_size_m_restored_8_17_0.xml"
_FIXTURE = _M_FIXTURE
@@ -57,6 +60,7 @@ 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.version = "8.17.0"
self.package = "com.xunmeng.pinduoduo"
self.screenshot = _png_base64() if screenshot is None else screenshot
@@ -99,7 +103,7 @@ class _RawDevice:
self.hierarchy = (
_EMPTY_FIXTURE.read_text(encoding="utf-8")
if self.fail_color_readback
else _COLOR_FIXTURE.read_text(encoding="utf-8")
else self.color_hierarchy
)
return
if (x, y) == (635, 1624):
@@ -177,6 +181,63 @@ def _mutate_unique_node(
return ElementTree.tostring(root, encoding="unicode")
def _nested_dual_price_layout(
hierarchy: str,
*,
header_bounds: str,
row_bounds: str,
summary_bounds: str,
content_bounds: str,
price_frame_bounds: str,
) -> str:
root = ElementTree.fromstring(hierarchy)
parents = {child: parent for parent in root.iter() for child in parent}
header = next(
node for node in root.iter("node")
if node.get("class") == "android.widget.LinearLayout"
and node.get("bounds") == header_bounds
)
row = next(
node for node in root.iter("node")
if node.get("class") == "android.widget.LinearLayout"
and node.get("bounds") == row_bounds
)
summary = next(
node for node in root.iter("node")
if node.get("class") == "android.widget.TextView"
and node.get("bounds") == summary_bounds
)
parents[row].remove(row)
parents[summary].remove(summary)
def inert(class_name: str, bounds: str) -> ElementTree.Element:
return ElementTree.Element(
"node",
{
"package": "com.xunmeng.pinduoduo",
"class": class_name,
"bounds": bounds,
"clickable": "false",
"enabled": "true",
"visible-to-user": "true",
"selected": "false",
"scrollable": "false",
},
)
content_frame = inert("android.widget.FrameLayout", content_bounds)
relative = inert("android.widget.RelativeLayout", content_bounds)
content = inert("android.view.ViewGroup", content_bounds)
price_frame = inert("android.widget.FrameLayout", price_frame_bounds)
header.append(content_frame)
content_frame.append(relative)
relative.append(content)
content.append(price_frame)
price_frame.append(row)
content.append(summary)
return ElementTree.tostring(root, encoding="unicode")
def _entry_chain(root: ElementTree.Element) -> list[ElementTree.Element]:
parents = {child: parent for parent in root.iter() for child in parent}
child = next(node for node in root.iter("node") if node.get("text") == "快要抢光 ¥ 12.88")
@@ -364,6 +425,169 @@ class SkuSelectionFlowTests(unittest.TestCase):
self.assertEqual(_tap_centers(restored), [(635, 1624)])
self.assertEqual(_actions(restored, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
def test_current_only_empty_to_color_only_uses_the_matching_exact_spec(self) -> None:
device = _RawDevice()
device.panel_hierarchy = _CURRENT_ONLY_EMPTY_FIXTURE.read_text(encoding="utf-8")
device.color_hierarchy = _CURRENT_ONLY_COLOR_FIXTURE.read_text(encoding="utf-8")
flow = _flow(device)
flow.open_sku_panel(_TARGET_URL)
with self.assertRaisesRegex(SkuSelectionError, "受控显示动作尚未取证"):
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(_tap_centers(device), [(865, 2218), (528, 1387)])
matched = _classify_panel(_parse_nodes(device.hierarchy))
self.assertIs(matched.profile, _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN)
self.assertEqual(
matched.price_layout.__class__.__name__,
"_CurrentOnlyPriceLayout",
)
def test_dual_and_current_only_variants_each_match_one_complete_spec(self) -> None:
cases = (
(_EMPTY_FIXTURE, _PanelProfile.PANEL_OPEN_EMPTY, "_DualPriceLayout"),
(_COLOR_FIXTURE, _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN, "_DualPriceLayout"),
(_CURRENT_ONLY_EMPTY_FIXTURE, _PanelProfile.PANEL_OPEN_EMPTY, "_CurrentOnlyPriceLayout"),
(_CURRENT_ONLY_COLOR_FIXTURE, _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN, "_CurrentOnlyPriceLayout"),
)
for fixture, profile, layout_name in cases:
with self.subTest(fixture=fixture.name):
spec = _classify_panel(_parse_nodes(fixture.read_text(encoding="utf-8")))
self.assertIs(spec.profile, profile)
self.assertEqual(spec.price_layout.__class__.__name__, layout_name)
def test_dual_price_historical_nested_parent_chains_remain_accepted(self) -> None:
for fixture, expected_profile in (
(_EMPTY_FIXTURE, _PanelProfile.PANEL_OPEN_EMPTY),
(_COLOR_FIXTURE, _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN),
):
with self.subTest(fixture=fixture.name):
nested = _nested_dual_price_layout(
fixture.read_text(encoding="utf-8"),
header_bounds="[0,366][1080,1077]",
row_bounds="[396,575][912,647]",
summary_bounds="[396,731][1053,793]",
content_bounds="[0,551][1080,940]",
price_frame_bounds="[396,575][1053,647]",
)
spec = _classify_panel(_parse_nodes(nested))
self.assertIs(spec.profile, expected_profile)
self.assertEqual(spec.price_layout.__class__.__name__, "_DualPriceLayout")
nested_s = _nested_dual_price_layout(
_S_FIXTURE.read_text(encoding="utf-8"),
header_bounds="[0,366][1080,1000]",
row_bounds="[396,498][895,570]",
summary_bounds="[396,654][1053,716]",
content_bounds="[0,474][1080,863]",
price_frame_bounds="[396,498][1053,570]",
)
self.assertIs(
_classify_panel(_parse_nodes(nested_s)).profile,
_PanelProfile.SIZE_VISIBLE_NON_TARGET,
)
nested_m = _nested_dual_price_layout(
_M_FIXTURE.read_text(encoding="utf-8"),
header_bounds="[0,366][1080,1000]",
row_bounds="[396,498][895,570]",
summary_bounds="[396,654][1053,716]",
content_bounds="[0,474][1080,863]",
price_frame_bounds="[396,498][1053,570]",
)
self.assertEqual(_flow(_RawDevice(nested_m)).read_sku_unit_price(), "12.88")
def test_current_only_price_variant_rejects_cross_variant_duplicate_unknown_and_parent_drift(self) -> None:
base = _CURRENT_ONLY_EMPTY_FIXTURE.read_text(encoding="utf-8")
def mutate(kind: str) -> str:
root = ElementTree.fromstring(base)
parents = {child: parent for parent in root.iter() for child in parent}
frame = next(
node for node in root.iter("node")
if node.get("class") == "android.widget.FrameLayout"
and node.get("bounds") == "[396,575][1053,647]"
)
row = next(
node for node in frame
if node.get("class") == "android.widget.LinearLayout"
and node.get("bounds") == "[396,575][693,647]"
)
current = next(node for node in row if node.get("text") == "限1件 ¥12.88 ")
if kind == "cross_variant":
ElementTree.SubElement(
row,
"node",
{
"text": "券前¥29.88",
"package": "com.xunmeng.pinduoduo",
"class": "android.widget.TextView",
"bounds": "[693,580][912,647]",
"clickable": "false",
"enabled": "true",
"visible-to-user": "true",
"selected": "false",
"scrollable": "false",
},
)
elif kind == "duplicate_frame":
parents[frame].append(ElementTree.fromstring(ElementTree.tostring(frame, encoding="unicode")))
elif kind == "duplicate_current":
row.append(ElementTree.fromstring(ElementTree.tostring(current, encoding="unicode")))
elif kind == "unknown_text":
current.set("text", "未知促销文字")
elif kind == "unknown_bounds":
frame.set("bounds", "[396,574][1053,647]")
elif kind == "unknown_child":
row.append(ElementTree.Element("node", dict(current.attrib) | {"text": "未知节点"}))
elif kind == "parent_drift":
frame.remove(row)
parents[frame].append(row)
elif kind == "summary_parent_drift":
summary = next(
node for node in root.iter("node")
if node.get("bounds") == "[396,731][1053,793]"
and node.get("class") == "android.widget.TextView"
)
parents[summary].remove(summary)
parents[parents[summary]].append(summary)
else:
raise AssertionError(kind)
return ElementTree.tostring(root, encoding="unicode")
for kind in (
"cross_variant",
"duplicate_frame",
"duplicate_current",
"unknown_text",
"unknown_bounds",
"unknown_child",
"parent_drift",
"summary_parent_drift",
):
with self.subTest(kind=kind), self.assertRaises(SkuSelectionError):
_classify_panel(_parse_nodes(mutate(kind)))
def test_color_click_rejects_dual_current_only_cross_variant_in_both_directions(self) -> None:
cases = (
(_CURRENT_ONLY_EMPTY_FIXTURE, _COLOR_FIXTURE),
(_EMPTY_FIXTURE, _CURRENT_ONLY_COLOR_FIXTURE),
)
for empty_fixture, color_fixture in cases:
with self.subTest(empty=empty_fixture.name, color=color_fixture.name):
device = _RawDevice()
device.panel_hierarchy = empty_fixture.read_text(encoding="utf-8")
device.color_hierarchy = color_fixture.read_text(encoding="utf-8")
flow = _flow(device)
flow.open_sku_panel(_TARGET_URL)
with self.assertRaises(SkuSelectionError) as raised:
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertNotIn("受控显示动作尚未取证", str(raised.exception))
self.assertEqual(_tap_centers(device), [(865, 2218), (528, 1387)])
self.assertEqual(_actions(device, "pressKey"), [])
def test_full_verified_entry_structure_taps_exact_text_child_once(self) -> None:
device = _RawDevice()
_flow(device).open_sku_panel(_TARGET_URL)
@@ -708,6 +932,8 @@ class SkuSelectionFlowTests(unittest.TestCase):
for fixture in (
_EMPTY_FIXTURE,
_COLOR_FIXTURE,
_CURRENT_ONLY_EMPTY_FIXTURE,
_CURRENT_ONLY_COLOR_FIXTURE,
_S_FIXTURE,
_M_FIXTURE,
_ENTRY_FIXTURE,
+5 -1
View File
@@ -25,7 +25,7 @@ write_paths:
- docs/current-state.md
---
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-05T06:50:17Z sha256=eefe97da57a283bca30d1e82645c5b913ee7ec241d99e2227e4f38e1dd0b3681 -->
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-05T07:19:17Z sha256=1312a296b0bc6dbbb56dff043bf3f8051cced0d06292e0bafef1295622381a91 -->
## 问题 / 背景
T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:详情页底部购买区第一行“快要抢光 + 金额”是唯一获准的受控规格入口;商品内容区同名小字和第二行“免拼购买”都不得点击。
@@ -290,6 +290,10 @@ T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space
### 2026-08-05T06:50:08Z · ila
2026-08-05 T-103 同版本单价格节点变体真机证据与实施方案(先证据、后判据):第一次正式 reveal 在 `sku_entry_panel_verify` fail closed。项目所有者已确认 force-stop 后颜色/尺码恢复未选;root 只读检查证明 PDD 8.17.0/PKG110/Android16/1080×2376/前台正确、summary=`请选择: 颜色分类 尺码`、selected-count=0、目标颜色 bounds `[372,1188][684,1587]` selected=false。证据目录 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-panel-empty-current-only-20260805-143741`,screenshot SHA-256 `7f3143d7fab42b41a088e27a4b2d9f8dbdf31b5ecc9ba02b148ca021304026f6`,XML SHA-256 `d3dbf217b4567069feccb30ab43124c5fa7da3734659ca5452b98c2e0a7ff158`。根因不是 selected,而是价格行由旧双节点变为 exact 单节点:current 仍为 `限1件 ¥12.88 ` `[396,580][675,647]`,其唯一 LinearLayout 为 `[396,575][693,647]` 且仅一 child,外层 FrameLayout `[396,575][1053,647]`;`券前¥29.88` count=0。项目所有者随后在同一 goods_id 937122477375 面板人工选择目标颜色并明确完成第二态确认;目录 `...\sku-panel-color-only-current-only-20260805-144550`,screenshot `931dc2d31f4e34872fdd97879abd076e414b6494569e8a587353266011610545`,XML `c686737942e5fdcebc76c2bc1d5cd9ae90a45f4a921297119ddaa4313758a034`;summary=`请选择: 尺码`,目标颜色 selected=true,未选尺码,current count=1/original count=0,固定 reveal 通道用现有代码只读验证 PASS,未发送 swipe。实施只允许新增两个 exact 单价格前置 spec(PANEL_OPEN_EMPTY / COLOR_SELECTED_SIZE_HIDDEN),旧双价格 spec 继续保留;不得把 original 泛化为 optional、不得接受任意 row/bounds/child count/price text,也不得修改 rolled candidate、数量、确认页、提交或支付。正式再跑前新增带正式 marker 的 reveal 白名单阶段码,至少能明确 `reveal_not_attempted` 与 `reveal_attempted/candidate/after/publish` 边界,异常正文和页面内容仍不外泄;补 cross-variant/duplicate/unknown/伪造 stage 失败测试。T-103 继续 Doing,先提交此证据计划,再实现并独立主审。
### 2026-08-05T07:19:02Z · ila
2026-08-05 第二态由用户人工确认:目标颜色“黑色 CHA(纯棉)”已选,尺码选项已显示且尚未选尺码。实现已按两组新 raw 证据收紧:新增 PANEL_OPEN_EMPTY / COLOR_SELECTED_SIZE_HIDDEN 的 exact current-only 价格布局;历史 dual 布局继续按已取证嵌套后代关系匹配;颜色点击后的后置条件绑定点击前完整 price_layout,禁止 dual 与 current-only 双向漂移。reveal 失败阶段新增不可伪造 marker,precondition 保证零 swipe,attempted 及以后仅在唯一 RPC 机会已封存后报告。两名独立 agent 审计均 PASS;根代理直接读取六组真实 raw 验证唯一命中:dual empty/color/S/M 与 current-only empty/color 均正确,M 读价 12.88。根代理完整门禁:client unittest 269/269 PASS,compileall PASS,validate_agent_context PASS,git diff --check PASS。未新增数量、确认页、围栏、提交订单或付款能力。T-103 仍保持 DOING,下一步仅允许强停重置后运行一次真机 reveal,并等待人工验收。
<!-- END VIKUNJA EXPORT -->
## 边界