feat(client): verify T-105 quantity Gate2

This commit is contained in:
QiuSW
2026-08-06 09:28:00 +08:00
parent 1d726dd364
commit 21628a4dc5
6 changed files with 1579 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
"""运行 T-105 数量 1→2、Gate2 截图与一次安全退出。"""
from __future__ import annotations
import argparse
from datetime import datetime
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.quantity_gate2 import (
EXPECTED_GATE1_UNIT_PRICE,
EXPECTED_GOODS_ID,
TASK_COLOR,
TASK_SIZE,
Gate1Observation,
QuantityGate2Error,
)
from cmbuyer_client.pdd.quantity_gate2_runner import QuantityGate2Runner
from cmbuyer_client.pdd.quantity_gate2_spike import Android16TopResumedForegroundReader
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="运行 T-105 已取证数量与 Gate2 闭环。")
parser.add_argument("--serial", required=True, help="显式 ADB serial;禁止自动选择。")
parser.add_argument("--goods-id", required=True)
parser.add_argument("--color", required=True)
parser.add_argument("--size", required=True)
parser.add_argument("--target-quantity", required=True, type=int)
parser.add_argument("--gate1-unit-price", required=True)
parser.add_argument("--gate1-screenshot", required=True, type=Path)
parser.add_argument("--gate1-captured-at", required=True)
parser.add_argument("--max-total-price", required=True)
parser.add_argument("--output-dir", required=True, type=Path)
parser.add_argument("--timeout", type=float, default=10.0)
parser.add_argument("--adb", default="adb")
return parser.parse_args(argv)
def validate_arguments(arguments: argparse.Namespace) -> datetime:
if type(arguments.serial) is not str or not arguments.serial.strip() or arguments.serial != arguments.serial.strip():
raise ValueError("必须显式提供非空 --serial。")
if arguments.goods_id != EXPECTED_GOODS_ID:
raise ValueError("--goods-id 不是 T-105 已批准目标。")
if arguments.color != TASK_COLOR or arguments.size != TASK_SIZE:
raise ValueError("颜色或尺码不是 T-105 已批准目标。")
if type(arguments.target_quantity) is not int or arguments.target_quantity != 2:
raise ValueError("本次真机验收只批准 --target-quantity 2。")
if arguments.gate1_unit_price != EXPECTED_GATE1_UNIT_PRICE:
raise ValueError("--gate1-unit-price 与已确认 Gate1 不一致。")
if not isinstance(arguments.gate1_screenshot, Path) or not arguments.gate1_screenshot.is_file():
raise ValueError("--gate1-screenshot 必须是现有显式文件。")
try:
captured_at = datetime.fromisoformat(arguments.gate1_captured_at)
except (TypeError, ValueError) as error:
raise ValueError("--gate1-captured-at 必须是带时区 ISO 时间。") from error
if captured_at.utcoffset() is None:
raise ValueError("--gate1-captured-at 必须带时区。")
if arguments.max_total_price != "40.00":
raise ValueError("本次真机验收固定 --max-total-price 40.00。")
if not isinstance(arguments.output_dir, Path) or not arguments.output_dir.name or arguments.output_dir.exists():
raise ValueError("--output-dir 必须是不存在的明确新目录。")
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 的有限数值。")
return captured_at
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
captured_at = validate_arguments(arguments)
except ValueError as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少采购工具真机依赖。", file=sys.stderr)
return 2
adb_runner = SubprocessAdbRunner(arguments.adb)
runner = QuantityGate2Runner(
AdbClient(adb_runner, timeout_seconds=arguments.timeout),
NoReconnectUiautomatorConnector(
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
u2.connect,
),
Android16TopResumedForegroundReader(adb_runner, arguments.timeout),
timeout_seconds=arguments.timeout,
)
gate1 = Gate1Observation(
color=arguments.color,
size=arguments.size,
quantity=1,
gate1_unit_price=arguments.gate1_unit_price,
screenshot_path=arguments.gate1_screenshot,
captured_at=captured_at,
)
try:
runner.run(
arguments.serial,
arguments.goods_id,
gate1,
arguments.target_quantity,
arguments.max_total_price,
arguments.output_dir,
)
except (DeviceConnectionError, QuantityGate2Error, OSError):
# 不回显第三方异常、serial、本机路径或页面正文。
print("T-105 数量/Gate2 运行失败:已停止,未发布证据目录。", file=sys.stderr)
return 1
print("T-105 数量/Gate2 运行完成。")
print("人工复核:数量 2、目标规格、12.88/32.76、原始截图和一次安全退出。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,605 @@
"""T-105:证据绑定的数量读回与 Gate2 面板总额。
本模块只允许数量 1 保持不变,或从数量 1 对唯一加号点击一次到数量 2。
它不包含确认页、提交围栏、提交订单或付款能力。
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from functools import partial
from math import isfinite
from pathlib import Path
import re
from time import monotonic, sleep
from typing import Any, Callable, Protocol
from xml.etree import ElementTree
from ..core.errors import ValidationError
from ..core.validation import require_money
from ..device.baseline import PDD_PACKAGE
from .product_open import EXPECTED_PDD_VERSION
from .sku_selection import SkuSelectionError, _parse_nodes as _parse_sku_nodes
from .sku_selection import _product_exit_projection
EXPECTED_DEVICE_MODEL = "PKG110"
EXPECTED_ANDROID_VERSION = "16"
EXPECTED_SCREEN_SIZE = (1080, 2376)
EXPECTED_GOODS_ID = "937122477375"
TASK_COLOR = "黑色CHA(纯棉)"
TASK_SIZE = "M(建议100-115)"
UI_COLOR = "黑色 CHA (纯棉)"
UI_SIZE = "M(建议100-115)"
EXPECTED_GATE1_UNIT_PRICE = "12.88"
_PDD_ID = "com.xunmeng.pinduoduo:id/pdd"
_QUANTITY_CONTAINER_ID = "com.xunmeng.pinduoduo:id/gnl"
_COLOR_ID = "com.xunmeng.pinduoduo:id/tv_content"
_TARGET_SUMMARY = f"已选: {UI_COLOR} {UI_SIZE}"
_AMOUNT_TEXT = re.compile(r"^快卖完 ¥(?P<amount>(?:0|[1-9]\d*)\.\d{2})$")
_BOUNDS = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$")
class QuantityGate2Error(RuntimeError):
"""数量/Gate2 判据不成立时的脱敏安全停止。"""
class QuantityGate2TimeoutError(QuantityGate2Error):
"""设备调用或后置条件等待超时。"""
class QuantityGate2OverCapError(QuantityGate2Error):
"""目标数量面板总额超过管理员授权上限。"""
class QuantityGate2Device(Protocol):
"""T-105 的窄设备能力;没有通用页面动作。"""
def app_info(self, package_name: str) -> dict[str, Any]: ...
def current_foreground(self) -> dict[str, str]: ...
def display_size(self) -> tuple[int, int]: ...
def dump_window_hierarchy(self) -> str: ...
def increment_quantity_once(self, bounds: str) -> None: ...
def capture_screenshot(self) -> str: ...
def leave_sku_panel_once(self) -> None: ...
@dataclass(frozen=True)
class Gate1Observation:
color: str
size: str
quantity: int
gate1_unit_price: str
screenshot_path: Path
captured_at: datetime
def __post_init__(self) -> None:
if self.color != TASK_COLOR or self.size != TASK_SIZE or type(self.quantity) is not int or self.quantity != 1:
raise QuantityGate2Error("Gate1 规格或数量不是已取证前置,已停止操作。")
if _money(self.gate1_unit_price) != EXPECTED_GATE1_UNIT_PRICE:
raise QuantityGate2Error("Gate1 单价不是已取证值,已停止操作。")
if not isinstance(self.screenshot_path, Path) or not self.screenshot_path.name:
raise QuantityGate2Error("Gate1 截图路径无效,已停止操作。")
if not isinstance(self.captured_at, datetime) or self.captured_at.utcoffset() is None:
raise QuantityGate2Error("Gate1 采集时间必须带时区,已停止操作。")
@dataclass(frozen=True)
class Gate2Observation:
requested_color: str
requested_size: str
actual_color: str
actual_size: str
requested_quantity: int
quantity_read: int
gate1_unit_price: str
gate2_panel_total_price: str
max_total_price: str
screenshot_path: Path
captured_at: datetime
@dataclass(frozen=True)
class _PanelProfile:
quantity: int
panel_bounds: str
price_row_bounds: str
price_bounds: str
price_text: str
summary_bounds: str
quantity_bounds: str
minus_bounds: str
value_bounds: str
plus_bounds: str
color_bounds: str
_INITIAL = _PanelProfile(
1,
"[0,474][1080,863]",
"[396,498][895,570]",
"[396,503][712,570]",
"快卖完 ¥12.88",
"[396,654][1053,716]",
"[396,752][645,827]",
"[396,752][474,827]",
"[480,752][561,827]",
"[567,752][645,827]",
"[126,1000][438,1024]",
)
_TARGET = _PanelProfile(
2,
"[0,474][1080,861]",
"[396,498][740,570]",
"[396,503][722,570]",
"快卖完 ¥32.76",
"[396,582][1053,644]",
"[396,750][645,825]",
"[396,750][474,825]",
"[480,750][561,825]",
"[567,750][645,825]",
"[126,998][438,1024]",
)
_PROFILES = {1: _INITIAL, 2: _TARGET}
@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", "")
@dataclass(frozen=True)
class _VerifiedPanel:
quantity: int
panel_total_price: str
plus_bounds: str
projection: tuple[object, ...]
class QuantityGate2Flow:
"""从已确认数量 1 面板推进至获准数量,并安全退出同一商品。"""
def __init__(
self,
device: QuantityGate2Device,
wait_timeout_seconds: float = 1.0,
poll_interval_seconds: float = 0.2,
monotonic_clock: Callable[[], float] = monotonic,
sleep_function: Callable[[float], None] = sleep,
) -> None:
if not _positive_finite(wait_timeout_seconds) or not _positive_finite(poll_interval_seconds):
raise ValueError("等待参数必须是大于 0 的有限数值。")
self._device = device
self._timeout = float(wait_timeout_seconds)
self._poll = float(poll_interval_seconds)
self._clock = monotonic_clock
self._sleep = sleep_function
self._increment_attempted = False
self._pending: tuple[str, Callable[[list[_Node]], _VerifiedPanel]] | None = None
self._terminal = False
self._verified_quantity: int | None = None
self._verified_total: str | None = None
self._exited = False
@property
def increment_attempts(self) -> int:
return int(self._increment_attempted)
@property
def exited(self) -> bool:
return self._exited
@property
def can_exit_safely(self) -> bool:
return self._pending is None and self._verified_quantity in _PROFILES and not self._exited
def set_quantity_and_verify(
self,
gate1: Gate1Observation,
target_quantity: int,
max_total_price: str,
) -> _VerifiedPanel:
self._require_active()
_validate_request(gate1, target_quantity, max_total_price)
initial = self._read_verified(_INITIAL)
if initial.panel_total_price != gate1.gate1_unit_price:
raise QuantityGate2Error("Gate1 当前面板事实已漂移,已停止操作。")
if target_quantity == 1:
verified = initial
else:
# 动作前 fresh 读取,不能使用上一次节点或缓存 bounds。
before = self._read_hierarchy()
precondition = _verified_panel(_parse_nodes(before), _INITIAL)
_require_unique_action_occupants(_parse_nodes(before), precondition.plus_bounds)
self._pending = (before, partial(_verified_panel, profile=_TARGET))
self._increment_attempted = True
try:
self._device.increment_quantity_once(precondition.plus_bounds)
verified = self._wait_for_pending()
except BaseException:
# 点击超时可能已送达;封存后不允许本 Flow 重试或继续。
self._terminal = True
raise
self._verified_quantity = verified.quantity
self._verified_total = verified.panel_total_price
if Decimal(verified.panel_total_price) > Decimal(max_total_price):
raise QuantityGate2OverCapError("Gate2 面板总额超过授权最高总价,已停止操作。")
return verified
def build_observation(
self,
gate1: Gate1Observation,
target_quantity: int,
max_total_price: str,
screenshot_path: Path,
captured_at: datetime,
) -> Gate2Observation:
self._require_active()
_validate_request(gate1, target_quantity, max_total_price)
if not isinstance(screenshot_path, Path) or not screenshot_path.name:
raise QuantityGate2Error("Gate2 截图路径无效,已停止操作。")
if not isinstance(captured_at, datetime) or captured_at.utcoffset() is None:
raise QuantityGate2Error("Gate2 采集时间必须带时区,已停止操作。")
verified = self._read_verified(_PROFILES[target_quantity])
if (
self._verified_quantity != verified.quantity
or self._verified_total != verified.panel_total_price
or Decimal(verified.panel_total_price) > Decimal(max_total_price)
):
raise QuantityGate2Error("截图后 Gate2 事实漂移,已停止操作。")
return Gate2Observation(
requested_color=gate1.color,
requested_size=gate1.size,
actual_color=TASK_COLOR,
actual_size=TASK_SIZE,
requested_quantity=target_quantity,
quantity_read=verified.quantity,
gate1_unit_price=gate1.gate1_unit_price,
gate2_panel_total_price=verified.panel_total_price,
max_total_price=max_total_price,
screenshot_path=screenshot_path,
captured_at=captured_at,
)
def exit_sku_panel_safely(self) -> None:
self._require_active()
if self._verified_quantity not in _PROFILES:
raise QuantityGate2Error("没有可用于安全退出的数量事实,已停止操作。")
current = self._read_verified(_PROFILES[self._verified_quantity])
if current.panel_total_price != self._verified_total:
raise QuantityGate2Error("安全退出前 Gate2 事实漂移,已停止操作。")
try:
self._device.leave_sku_panel_once()
except BaseException:
self._terminal = True
raise
deadline = self._clock() + self._timeout
stable: tuple[object, ...] | None = None
try:
while True:
self._require_environment()
raw = self._read_hierarchy()
try:
projection = _product_exit_projection(_parse_sku_nodes(raw))
except SkuSelectionError:
stable = None
else:
self._require_environment()
if stable == projection:
self._exited = True
self._terminal = True
return
stable = projection
remaining = deadline - self._clock()
if remaining <= 0:
raise QuantityGate2TimeoutError("安全退出未达到连续稳定同商品判据,未重试返回。")
self._sleep(min(self._poll, remaining))
except BaseException:
self._terminal = True
raise
def reconcile_pending_action(self) -> _VerifiedPanel | None:
"""结果不明时只读一次待定后置条件;绝不重发加号。"""
if self._pending is None:
return None
return self._wait_for_pending()
def _wait_for_pending(self) -> _VerifiedPanel:
if self._pending is None:
raise QuantityGate2Error("没有可调和的数量动作。")
previous, condition = self._pending
deadline = self._clock() + self._timeout
while True:
self._require_environment()
raw = self._read_hierarchy()
if raw != previous:
try:
verified = condition(_parse_nodes(raw))
except QuantityGate2Error:
pass
else:
self._pending = None
return verified
remaining = deadline - self._clock()
if remaining <= 0:
raise QuantityGate2TimeoutError("数量动作后置条件未确认,未重试加号。")
self._sleep(min(self._poll, remaining))
def _read_verified(self, profile: _PanelProfile) -> _VerifiedPanel:
self._require_environment()
return _verified_panel(_parse_nodes(self._read_hierarchy()), profile)
def _require_environment(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 QuantityGate2Error("拼多多版本与 T-105 证据不一致,已停止操作。")
foreground = self._device.current_foreground()
if (
not isinstance(foreground, dict)
or foreground.get("package") != PDD_PACKAGE
or not isinstance(foreground.get("activity"), str)
or not foreground["activity"].strip()
):
raise QuantityGate2Error("拼多多不是唯一前台应用,已停止操作。")
if self._device.display_size() != EXPECTED_SCREEN_SIZE:
raise QuantityGate2Error("屏幕坐标空间与 T-105 证据不一致,已停止操作。")
def _read_hierarchy(self) -> str:
value = self._device.dump_window_hierarchy()
if not isinstance(value, str) or not value:
raise QuantityGate2Error("节点树读取失败,已停止操作。")
return value
def _require_active(self) -> None:
if self._terminal:
raise QuantityGate2Error("数量流程已进入不可重入终止态。")
def _positive_finite(value: object) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
def _validate_request(gate1: object, target_quantity: object, max_total_price: object) -> None:
if not isinstance(gate1, Gate1Observation):
raise QuantityGate2Error("缺少可信 Gate1Observation,已停止操作。")
if type(target_quantity) is not int or target_quantity not in _PROFILES:
raise QuantityGate2Error("目标数量没有本项目真机证据,已停止操作。")
_money(max_total_price)
def _money(value: object) -> str:
try:
return require_money(value, "invalid_money")
except ValidationError as error:
raise QuantityGate2Error("金额不是规范十进制字符串,已停止操作。") from error
def _parse_nodes(raw: str) -> list[_Node]:
try:
root = ElementTree.fromstring(raw)
except ElementTree.ParseError as error:
raise QuantityGate2Error("节点树格式无效,已停止操作。") from error
if root.tag != "hierarchy":
raise QuantityGate2Error("节点树根节点无效,已停止操作。")
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 _verified_panel(nodes: list[_Node], profile: _PanelProfile) -> _VerifiedPanel:
panel = _one(
node for node in nodes
if _exact(node, "android.view.ViewGroup", profile.panel_bounds, resource_id=_PDD_ID, clickable="false")
)
price_frame = _one(
node for node in nodes
if node.parent is panel and _exact(node, "android.widget.FrameLayout", "[396,498][1053,570]", resource_id=_PDD_ID, clickable="false")
)
price_row = _one(
node for node in nodes
if node.parent is price_frame and _exact(node, "android.widget.LinearLayout", profile.price_row_bounds, resource_id=_PDD_ID, clickable="false")
)
price = _one(
node for node in nodes
if node.parent is price_row
and _exact(node, "android.widget.TextView", profile.price_bounds, resource_id=_PDD_ID, clickable="false")
and node.text == profile.price_text
and not node.desc
)
match = _AMOUNT_TEXT.fullmatch(price.text)
if match is None:
raise QuantityGate2Error("Gate2 面板总额角色不可读。")
amount = _money(match.group("amount"))
_one(
node for node in nodes
if node.parent is panel
and _exact(node, "android.widget.TextView", profile.summary_bounds, resource_id=_PDD_ID, clickable="false")
and node.text == _TARGET_SUMMARY
and not node.desc
)
quantity_outer = _one(
node for node in nodes
if node.parent is panel
and _exact(node, "android.widget.LinearLayout", profile.quantity_bounds, resource_id=_QUANTITY_CONTAINER_ID, clickable="false")
)
quantity_inner = _one(
node for node in nodes
if node.parent is quantity_outer
and _exact(node, "android.widget.LinearLayout", profile.quantity_bounds, resource_id="", clickable="false")
)
_one(
node for node in nodes
if node.parent is quantity_inner
and _exact(node, "android.widget.ImageView", profile.minus_bounds, resource_id=_PDD_ID, clickable="true")
and node.desc == "减少数量"
and not node.text
)
quantity = _one(
node for node in nodes
if node.parent is quantity_inner
and _exact(node, "android.widget.EditText", profile.value_bounds, resource_id=_PDD_ID, clickable="true")
and node.text == str(profile.quantity)
and not node.desc
)
plus = _one(
node for node in nodes
if node.parent is quantity_inner
and _exact(node, "android.widget.ImageView", profile.plus_bounds, resource_id=_PDD_ID, clickable="true")
and node.desc == "增加数量"
and not node.text
)
if quantity.element.get("selected") != "false" or plus.element.get("selected") != "false":
raise QuantityGate2Error("数量控件选中属性漂移。")
_require_selected_target(nodes, profile)
projection = (
"quantity_gate2_8_17_0",
profile.quantity,
amount,
tuple(_projection(node) for node in (panel, price_frame, price_row, price, quantity_outer, quantity_inner, quantity, plus)),
)
return _VerifiedPanel(profile.quantity, amount, plus.bounds, projection)
def _require_selected_target(nodes: list[_Node], profile: _PanelProfile) -> None:
color = _one(
node for node in nodes
if _exact(node, "android.widget.TextView", profile.color_bounds, resource_id=_COLOR_ID, clickable="true", selected="true")
and node.text == UI_COLOR
and not node.desc
)
if color.parent is None or color.parent.element.get("selected") != "true":
raise QuantityGate2Error("目标颜色没有精确选中。")
size = _one(
node for node in nodes
if _exact(node, "android.widget.TextView", "[439,1582][831,1667]", resource_id=_PDD_ID, clickable="true", selected="true")
and node.text == UI_SIZE
and not node.desc
)
if size.parent is None or size.parent.element.get("clickable") != "true":
raise QuantityGate2Error("目标尺码结构漂移。")
def _exact(
node: _Node,
class_name: str,
bounds: str,
*,
resource_id: str,
clickable: str,
selected: str = "false",
) -> bool:
element = node.element
return (
element.get("package") == PDD_PACKAGE
and element.get("class") == class_name
and node.bounds == bounds
and element.get("resource-id", "") == resource_id
and element.get("clickable") == clickable
and element.get("selected") == selected
and element.get("enabled") == "true"
and element.get("visible-to-user") == "true"
and element.get("scrollable") == "false"
)
def _one(values: Any) -> _Node:
matches = list(values)
if len(matches) != 1:
raise QuantityGate2Error("T-105 页面证据角色缺失或不唯一。")
return matches[0]
def _projection(node: _Node) -> tuple[str, ...]:
return (
node.element.tag,
node.element.get("package", ""),
node.element.get("class", ""),
node.bounds,
node.element.get("resource-id", ""),
node.element.get("clickable", ""),
node.element.get("selected", ""),
node.text,
node.desc,
)
def _bounds_center(bounds: str) -> tuple[int, int]:
match = _BOUNDS.fullmatch(bounds)
if match is None:
raise QuantityGate2Error("数量控件坐标无效。")
left, top, right, bottom = (int(value) for value in match.groups())
if not (0 <= left < right <= EXPECTED_SCREEN_SIZE[0] and 0 <= top < bottom <= EXPECTED_SCREEN_SIZE[1]):
raise QuantityGate2Error("数量控件坐标超出已取证屏幕。")
return left + (right - left) // 2, top + (bottom - top) // 2
def _require_unique_action_occupants(nodes: list[_Node], bounds: str) -> None:
target = _one(node for node in nodes if node.bounds == bounds and node.desc == "增加数量")
x, y = _bounds_center(bounds)
occupants = [
node for node in nodes
if node.element.get("clickable") == "true"
and node.element.get("enabled") == "true"
and node.element.get("visible-to-user") == "true"
and _contains(node.bounds, x, y)
]
if not occupants or any(not _same_branch(node, target) for node in occupants):
raise QuantityGate2Error("数量加号中心存在未知可点击覆盖层,已停止操作。")
def _contains(bounds: str, x: int, y: int) -> bool:
match = _BOUNDS.fullmatch(bounds)
if match is None:
return False
left, top, right, bottom = (int(value) for value in match.groups())
return left <= x < right and top <= y < bottom
def _same_branch(candidate: _Node, target: _Node) -> bool:
current: _Node | None = target
while current is not None:
if current.element is candidate.element:
return True
current = current.parent
current = candidate
while current is not None:
if current.element is target.element:
return True
current = current.parent
return False
@@ -0,0 +1,397 @@
"""T-105 真机运行边界:一次数量加号、Gate2 原图与一次安全退出。"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, replace
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, Protocol
from uuid import uuid4
from adbutils.errors import AdbTimeout
from PIL import Image, UnidentifiedImageError
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 .quantity_gate2 import (
EXPECTED_ANDROID_VERSION,
EXPECTED_DEVICE_MODEL,
EXPECTED_GOODS_ID,
EXPECTED_SCREEN_SIZE,
Gate1Observation,
Gate2Observation,
QuantityGate2Device,
QuantityGate2Error,
QuantityGate2Flow,
QuantityGate2TimeoutError,
_bounds_center,
_money,
)
class ForegroundReader(Protocol):
def read(self, serial: str) -> dict[str, str]: ...
class QuantityGate2AdapterError(QuantityGate2Error):
"""第三方设备接口失败后的脱敏映射。"""
@dataclass(frozen=True)
class QuantityGate2RunResult:
output_directory: Path
screenshot_path: Path
manifest_path: Path
observation: Gate2Observation
class UiautomatorQuantityGate2Adapter(QuantityGate2Device):
"""只暴露 T-105 已批准的一个加号和一个 Back。"""
def __init__(
self,
device: Any,
foreground_reader: ForegroundReader,
serial: str,
timeout_seconds: float,
) -> None:
if type(serial) is not str or not serial.strip() or serial != serial.strip():
raise ValueError("serial 必须显式且非空。")
if not _positive_finite(timeout_seconds):
raise ValueError("timeout_seconds 必须是大于 0 的有限数值。")
self._device = device
self._foreground_reader = foreground_reader
self._serial = serial
self._timeout = float(timeout_seconds)
self._increment_attempted = False
self._increment_bounds: str | None = None
self._increment_outcome = "not_attempted"
self._back_attempted = False
self._back_outcome = "not_attempted"
@property
def increment_attempts(self) -> int:
return int(self._increment_attempted)
@property
def increment_bounds(self) -> str | None:
return self._increment_bounds
@property
def increment_outcome(self) -> str:
return self._increment_outcome
@property
def back_attempts(self) -> int:
return int(self._back_attempted)
@property
def back_outcome(self) -> str:
return self._back_outcome
def app_info(self, package_name: str) -> dict[str, Any]:
value = self._call("app_info", package_name)
if not isinstance(value, dict):
raise QuantityGate2AdapterError("无法读取应用版本,已停止操作。")
return value
def current_foreground(self) -> dict[str, str]:
try:
value = self._foreground_reader.read(self._serial)
except QuantityGate2Error:
raise
except Exception as error:
raise QuantityGate2AdapterError("无法读取 Android 前台摘要,已停止操作。") from error
if not isinstance(value, dict):
raise QuantityGate2AdapterError("Android 前台摘要无效,已停止操作。")
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(type(item) is not int for item in value):
raise QuantityGate2AdapterError("无法读取屏幕坐标空间,已停止操作。")
return value
def dump_window_hierarchy(self) -> str:
value = self._call("jsonrpc_call", "dumpWindowHierarchy", [False, 50], timeout=self._timeout)
if not isinstance(value, str):
raise QuantityGate2AdapterError("节点树读取失败,已停止操作。")
return value
def increment_quantity_once(self, bounds: str) -> None:
if self._increment_attempted:
raise QuantityGate2AdapterError("数量加号已尝试过,拒绝重试。")
center_x, center_y = _bounds_center(bounds)
# RPC 超时无法证明事件未送达,动作机会必须先持久在内存审计状态中。
self._increment_attempted = True
self._increment_bounds = bounds
self._increment_outcome = "ambiguous"
self._call("jsonrpc_call", "click", [center_x, center_y], timeout=self._timeout)
self._increment_outcome = "completed"
def capture_screenshot(self) -> str:
value = self._call("jsonrpc_call", "takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout)
if not isinstance(value, str):
raise QuantityGate2AdapterError("Gate2 原始截图读取失败,已停止操作。")
return value
def leave_sku_panel_once(self) -> None:
if self._back_attempted:
raise QuantityGate2AdapterError("安全返回已尝试过,拒绝重试。")
self._back_attempted = True
self._back_outcome = "ambiguous"
self._call("jsonrpc_call", "pressKey", ["back"], timeout=self._timeout)
self._back_outcome = "completed"
def _call(self, method: str, *args: Any, **kwargs: Any) -> Any:
try:
return getattr(self._device, method)(*args, **kwargs)
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
raise QuantityGate2TimeoutError("T-105 设备调用超时,已停止操作。") from error
except QuantityGate2Error:
raise
except Exception as error:
raise QuantityGate2AdapterError("T-105 设备调用失败,已停止操作。") from error
class QuantityGate2Runner:
"""从人工停驻的数量 1 目标面板执行 T-105 已取证闭环。"""
def __init__(
self,
adb_client: AdbClient,
connector: Callable[[str], Any],
foreground_reader: ForegroundReader,
timeout_seconds: float,
monotonic_clock: Callable[[], float] = monotonic,
) -> None:
if not _positive_finite(timeout_seconds):
raise ValueError("timeout_seconds 必须是大于 0 的有限数值。")
self._adb_client = adb_client
self._connector = connector
self._foreground_reader = foreground_reader
self._timeout = float(timeout_seconds)
self._clock = monotonic_clock
def run(
self,
serial: str,
goods_id: str,
gate1: Gate1Observation,
target_quantity: int,
max_total_price: str,
output_directory: Path,
) -> QuantityGate2RunResult:
target = Path(output_directory)
staging: Path | None = None
adapter: UiautomatorQuantityGate2Adapter | None = None
flow: QuantityGate2Flow | None = None
try:
_validate_preflight(serial, goods_id, gate1, target_quantity, max_total_price, target)
staging = _prepare_staging(target)
inspection = self._adb_client.inspect(serial)
_require_expected_device(inspection)
adapter = UiautomatorQuantityGate2Adapter(
self._connector(serial),
self._foreground_reader,
serial,
self._timeout,
)
flow = QuantityGate2Flow(
adapter,
wait_timeout_seconds=self._timeout,
monotonic_clock=self._clock,
)
flow.set_quantity_and_verify(gate1, target_quantity, max_total_price)
screenshot_path = staging / "gate2_screenshot.png"
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
captured_at = datetime.now(UTC)
_require_screenshot_size(screenshot_path)
observation = flow.build_observation(
gate1,
target_quantity,
max_total_price,
screenshot_path,
captured_at,
)
flow.exit_sku_panel_safely()
_require_action_audit(adapter, target_quantity)
manifest_path = staging / "manifest.json"
manifest_path.write_text(
json.dumps(
_manifest(inspection, serial, gate1, observation, screenshot_path, adapter),
ensure_ascii=False,
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
os.rename(staging, target)
staging = None
except (DeviceConnectionError, QuantityGate2Error):
_attempt_known_safe_exit(flow)
_clean_staging(staging)
raise
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
_attempt_known_safe_exit(flow)
_clean_staging(staging)
raise QuantityGate2TimeoutError("T-105 真机运行超时,未发布证据。") from error
except OSError as error:
_attempt_known_safe_exit(flow)
_clean_staging(staging)
raise QuantityGate2Error("T-105 证据无法原子发布。") from error
except Exception as error:
_attempt_known_safe_exit(flow)
_clean_staging(staging)
raise QuantityGate2Error("T-105 真机运行未完成。") from error
published_observation = replace(
observation,
screenshot_path=target / "gate2_screenshot.png",
)
return QuantityGate2RunResult(
output_directory=target,
screenshot_path=target / "gate2_screenshot.png",
manifest_path=target / "manifest.json",
observation=published_observation,
)
def _positive_finite(value: object) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
def _validate_preflight(
serial: object,
goods_id: object,
gate1: object,
target_quantity: object,
max_total_price: object,
target: Path,
) -> None:
if type(serial) is not str or not serial.strip() or serial != serial.strip():
raise QuantityGate2Error("必须显式提供非空设备通道。")
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
raise QuantityGate2Error("商品不是 T-105 已批准目标。")
if not isinstance(gate1, Gate1Observation) or not gate1.screenshot_path.is_file():
raise QuantityGate2Error("Gate1 原始截图不存在,已停止操作。")
if type(target_quantity) is not int or target_quantity not in {1, 2}:
raise QuantityGate2Error("目标数量没有 T-105 真机证据。")
_money(max_total_price)
if target.exists() or not target.name:
raise QuantityGate2Error("输出目录必须是不存在的明确新目录。")
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()
return staging
except OSError as error:
_clean_staging(staging)
raise QuantityGate2Error("输出目录不可写,已停止操作。") from error
def _clean_staging(staging: Path | None) -> None:
if staging is not None and staging.exists():
shutil.rmtree(staging)
def _require_expected_device(inspection: DeviceInspection) -> None:
if inspection.model != EXPECTED_DEVICE_MODEL or inspection.android_version != EXPECTED_ANDROID_VERSION:
raise QuantityGate2Error("设备型号或 Android 版本与 T-105 证据不一致。")
def _require_screenshot_size(path: Path) -> None:
try:
with Image.open(path) as image:
image.load()
if image.size != EXPECTED_SCREEN_SIZE:
raise QuantityGate2Error("Gate2 截图尺寸与 T-105 证据不一致。")
except QuantityGate2Error:
raise
except (OSError, UnidentifiedImageError) as error:
raise QuantityGate2Error("Gate2 截图不是有效图像。") from error
def _require_action_audit(adapter: UiautomatorQuantityGate2Adapter, target_quantity: int) -> None:
expected_increment = int(target_quantity == 2)
if (
adapter.increment_attempts != expected_increment
or (expected_increment and adapter.increment_outcome != "completed")
or (expected_increment and adapter.increment_bounds != "[567,752][645,827]")
or (not expected_increment and adapter.increment_outcome != "not_attempted")
or adapter.back_attempts != 1
or adapter.back_outcome != "completed"
):
raise QuantityGate2Error("T-105 动作审计链不完整,拒绝发布。")
def _attempt_known_safe_exit(flow: QuantityGate2Flow | None) -> None:
if flow is None or not flow.can_exit_safely:
return
try:
flow.exit_sku_panel_safely()
except Exception:
pass
def _manifest(
inspection: DeviceInspection,
serial: str,
gate1: Gate1Observation,
observation: Gate2Observation,
screenshot_path: Path,
adapter: UiautomatorQuantityGate2Adapter,
) -> dict[str, Any]:
return {
"schema_version": 1,
"operation": "t105-quantity-gate2",
"captured_at": observation.captured_at.isoformat(),
"product": {"goods_id": EXPECTED_GOODS_ID},
"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,
},
"selection": {"color": observation.actual_color, "size": observation.actual_size},
"quantity": {"requested": observation.requested_quantity, "read": observation.quantity_read},
"prices": {
"gate1_unit_price": observation.gate1_unit_price,
"gate2_panel_total_price": observation.gate2_panel_total_price,
"max_total_price": observation.max_total_price,
},
"gate1_evidence": {
"captured_at": gate1.captured_at.isoformat(),
"screenshot_sha256": _sha256_file(gate1.screenshot_path),
},
"gate2_evidence": {
"path": screenshot_path.name,
"sha256": _sha256_file(screenshot_path),
},
"action_audit": {
"increment_attempts": adapter.increment_attempts,
"increment_rpc_outcome": adapter.increment_outcome,
"back_attempts": adapter.back_attempts,
"back_rpc_outcome": adapter.back_outcome,
},
"safe_exit": "completed",
"review_status": "human_review_required",
}
@@ -0,0 +1,30 @@
<hierarchy rotation="0">
<!-- T-105 只保留人工确认数量 1 证据中的规格、顶部金额与数量控件。 -->
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[0,474][1080,863]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,498][1053,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,498][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="快卖完 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,503][712,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
<node text="已选: 黑色 CHA (纯棉) M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,654][1053,716]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" resource-id="com.xunmeng.pinduoduo:id/gnl" bounds="[396,752][645,827]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,752][645,827]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node content-desc="减少数量" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,752][474,827]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node text="1" package="com.xunmeng.pinduoduo" class="android.widget.EditText" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[480,752][561,827]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node content-desc="增加数量" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[567,752][645,827]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[126,1000][438,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[126,1000][438,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/tv_content" bounds="[126,1000][438,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
</node>
<!-- 底部金额故意保留为反例;生产读取器只认上方证据角色。 -->
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[0,2181][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="提交订单 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[369,2225][710,2284]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</hierarchy>
@@ -0,0 +1,30 @@
<hierarchy rotation="0">
<!-- T-105 只保留人工确认数量 2 证据中的规格、顶部金额与数量控件。 -->
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[0,474][1080,861]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,498][1053,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,498][740,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="快卖完 ¥32.76" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,503][722,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
<node text="已选: 黑色 CHA (纯棉) M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,582][1053,644]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" resource-id="com.xunmeng.pinduoduo:id/gnl" bounds="[396,750][645,825]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,750][645,825]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node content-desc="减少数量" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,750][474,825]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node text="2" package="com.xunmeng.pinduoduo" class="android.widget.EditText" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[480,750][561,825]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node content-desc="增加数量" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[567,750][645,825]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[126,998][438,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[126,998][438,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/tv_content" bounds="[126,998][438,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
</node>
<!-- 与 Gate2 值相同也不能成为读取候选。 -->
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[0,2181][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="提交订单 ¥32.76" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[366,2225][714,2284]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</hierarchy>
+385
View File
@@ -6,6 +6,7 @@ import argparse
import ast import ast
import base64 import base64
from contextlib import redirect_stderr from contextlib import redirect_stderr
from datetime import UTC, datetime
from functools import lru_cache from functools import lru_cache
from importlib.util import module_from_spec, spec_from_file_location from importlib.util import module_from_spec, spec_from_file_location
from io import BytesIO, StringIO from io import BytesIO, StringIO
@@ -33,6 +34,14 @@ from cmbuyer_client.pdd.quantity_gate2_spike import (
QuantityGate2ForegroundReader, QuantityGate2ForegroundReader,
QuantityGate2ReadDevice, QuantityGate2ReadDevice,
) )
from cmbuyer_client.pdd.quantity_gate2 import (
Gate1Observation,
QuantityGate2Device,
QuantityGate2Error,
QuantityGate2Flow,
QuantityGate2OverCapError,
)
from cmbuyer_client.pdd.quantity_gate2_runner import QuantityGate2Runner
SERIAL = "192.168.0.173:5555" SERIAL = "192.168.0.173:5555"
@@ -144,6 +153,15 @@ def _load_script() -> object:
return module return module
def _load_run_script() -> object:
script_path = CLIENT_ROOT / "scripts" / "run_t105_quantity_gate2.py"
spec = spec_from_file_location("run_t105_quantity_gate2_for_test", script_path)
assert spec is not None and spec.loader is not None
module = module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _namespace(**changes: object) -> argparse.Namespace: def _namespace(**changes: object) -> argparse.Namespace:
values: dict[str, object] = { values: dict[str, object] = {
"serial": SERIAL, "serial": SERIAL,
@@ -555,5 +573,372 @@ class QuantityGate2CliAndStaticBoundaryTests(unittest.TestCase):
self.assertNotIn(forbidden_import, source.read_text(encoding="utf-8")) self.assertNotIn(forbidden_import, source.read_text(encoding="utf-8"))
_INITIAL_FIXTURE = CLIENT_ROOT / "tests" / "pdd" / "fixtures" / "quantity_gate2_initial_8_17_0.xml"
_TARGET_FIXTURE = CLIENT_ROOT / "tests" / "pdd" / "fixtures" / "quantity_gate2_target_8_17_0.xml"
_EXIT_FIXTURE = CLIENT_ROOT / "tests" / "pdd" / "fixtures" / "product_exit_8_17_0.xml"
def _gate1(path: Path = Path("gate1.png")) -> Gate1Observation:
return Gate1Observation(
color="黑色CHA(纯棉)",
size="M(建议100-115)",
quantity=1,
gate1_unit_price="12.88",
screenshot_path=path,
captured_at=datetime(2026, 8, 6, 0, 53, tzinfo=UTC),
)
class FakeQuantityFlowDevice:
def __init__(self, initial: str | None = None, target: str | None = None) -> None:
self.initial = initial or _INITIAL_FIXTURE.read_text(encoding="utf-8")
self.target = target or _TARGET_FIXTURE.read_text(encoding="utf-8")
self.exit = _EXIT_FIXTURE.read_text(encoding="utf-8")
self.current = self.initial
self.version = "8.17.0"
self.foreground = {"package": "com.xunmeng.pinduoduo", "activity": ".activity.NewPageActivity"}
self.screen_size = (1080, 2376)
self.increment_calls: list[str] = []
self.back_calls = 0
self.dump_calls = 0
self.raise_increment: BaseException | None = None
def app_info(self, package_name: str) -> dict[str, str]:
return {"versionName": self.version}
def current_foreground(self) -> dict[str, str]:
return dict(self.foreground)
def display_size(self) -> tuple[int, int]:
return self.screen_size
def dump_window_hierarchy(self) -> str:
self.dump_calls += 1
return self.current
def increment_quantity_once(self, bounds: str) -> None:
self.increment_calls.append(bounds)
if self.raise_increment is not None:
raise self.raise_increment
self.current = self.target
def capture_screenshot(self) -> str:
return _png_base64()
def leave_sku_panel_once(self) -> None:
self.back_calls += 1
self.current = self.exit
class QuantityGate2FlowTests(unittest.TestCase):
def test_quantity_two_reads_nonlinear_panel_total_and_exits_once(self) -> None:
device = FakeQuantityFlowDevice()
flow = QuantityGate2Flow(device, wait_timeout_seconds=0.1, poll_interval_seconds=0.01)
verified = flow.set_quantity_and_verify(_gate1(), 2, "40.00")
observation = flow.build_observation(
_gate1(),
2,
"40.00",
Path("gate2.png"),
datetime(2026, 8, 6, 1, 10, tzinfo=UTC),
)
flow.exit_sku_panel_safely()
self.assertEqual(verified.quantity, 2)
self.assertEqual(observation.quantity_read, 2)
self.assertEqual(observation.gate1_unit_price, "12.88")
self.assertEqual(observation.gate2_panel_total_price, "32.76")
self.assertEqual(observation.max_total_price, "40.00")
self.assertEqual(device.increment_calls, ["[567,752][645,827]"])
self.assertEqual(device.back_calls, 1)
self.assertTrue(flow.exited)
def test_quantity_one_is_zero_click_and_still_uses_panel_amount(self) -> None:
device = FakeQuantityFlowDevice()
flow = QuantityGate2Flow(device)
verified = flow.set_quantity_and_verify(_gate1(), 1, "12.88")
self.assertEqual((verified.quantity, verified.panel_total_price), (1, "12.88"))
self.assertEqual(device.increment_calls, [])
def test_only_evidenced_quantities_and_canonical_decimal_cap_are_allowed(self) -> None:
for quantity in (0, 3, -1, True, "2"):
with self.subTest(quantity=quantity):
device = FakeQuantityFlowDevice()
with self.assertRaises(QuantityGate2Error):
QuantityGate2Flow(device).set_quantity_and_verify(_gate1(), quantity, "40.00") # type: ignore[arg-type]
self.assertEqual(device.increment_calls, [])
for cap in ("0.00", "40", "040.00", 40.0, "NaN"):
with self.subTest(cap=cap):
device = FakeQuantityFlowDevice()
with self.assertRaises(QuantityGate2Error):
QuantityGate2Flow(device).set_quantity_and_verify(_gate1(), 2, cap) # type: ignore[arg-type]
self.assertEqual(device.increment_calls, [])
def test_over_cap_stops_after_exact_readback_without_fabricated_math(self) -> None:
device = FakeQuantityFlowDevice()
flow = QuantityGate2Flow(device)
with self.assertRaises(QuantityGate2OverCapError):
flow.set_quantity_and_verify(_gate1(), 2, "30.00")
self.assertEqual(device.increment_calls, ["[567,752][645,827]"])
self.assertTrue(flow.can_exit_safely)
def test_increment_timeout_is_sealed_and_cannot_be_retried(self) -> None:
device = FakeQuantityFlowDevice()
device.raise_increment = TimeoutError("ambiguous delivery")
flow = QuantityGate2Flow(device, wait_timeout_seconds=0.01, poll_interval_seconds=0.005)
with self.assertRaises(TimeoutError):
flow.set_quantity_and_verify(_gate1(), 2, "40.00")
with self.assertRaises(QuantityGate2Error):
flow.set_quantity_and_verify(_gate1(), 2, "40.00")
self.assertEqual(len(device.increment_calls), 1)
self.assertFalse(flow.can_exit_safely)
def test_missing_duplicate_drift_and_overlay_fail_before_click(self) -> None:
initial = _INITIAL_FIXTURE.read_text(encoding="utf-8")
plus = '<node content-desc="增加数量" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[567,752][645,827]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />'
variants = (
initial.replace('content-desc="增加数量"', 'content-desc="数量加一"', 1),
initial.replace(plus, plus + plus),
initial.replace('text="M(建议100-115)"', 'text="S(建议80-100)"', 1),
initial.replace(
"</hierarchy>",
'<node package="com.example.overlay" class="android.view.ViewGroup" bounds="[560,740][660,840]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" /></hierarchy>',
),
)
for hierarchy in variants:
with self.subTest(marker=hierarchy[-180:]):
device = FakeQuantityFlowDevice(initial=hierarchy)
with self.assertRaises(QuantityGate2Error):
QuantityGate2Flow(device).set_quantity_and_verify(_gate1(), 2, "40.00")
self.assertEqual(device.increment_calls, [])
def test_foreground_version_and_screen_drift_are_zero_click(self) -> None:
devices = (FakeQuantityFlowDevice(), FakeQuantityFlowDevice(), FakeQuantityFlowDevice())
devices[0].foreground = {"package": "com.android.systemui", "activity": ".Keyguard"}
devices[1].version = "8.17.1"
devices[2].screen_size = (1080, 2400)
for device in devices:
with self.subTest(device=device):
with self.assertRaises(QuantityGate2Error):
QuantityGate2Flow(device).set_quantity_and_verify(_gate1(), 2, "40.00")
self.assertEqual(device.increment_calls, [])
def test_bottom_submit_amount_is_never_a_gate2_candidate(self) -> None:
target = _TARGET_FIXTURE.read_text(encoding="utf-8").replace("提交订单 ¥32.76", "提交订单 ¥999.99")
device = FakeQuantityFlowDevice(target=target)
result = QuantityGate2Flow(device).set_quantity_and_verify(_gate1(), 2, "40.00")
self.assertEqual(result.panel_total_price, "32.76")
def test_gate1_target_spec_and_timestamp_are_strict(self) -> None:
invalid = (
{"color": "白色"},
{"size": "L"},
{"quantity": 2},
{"gate1_unit_price": "12.89"},
{"captured_at": datetime(2026, 8, 6, 0, 53)},
)
base = _gate1().__dict__
for change in invalid:
with self.subTest(change=change), self.assertRaises(QuantityGate2Error):
Gate1Observation(**{**base, **change})
class FakeRawQuantityDevice:
def __init__(self, *, click_timeout: bool = False) -> None:
self.current = _INITIAL_FIXTURE.read_text(encoding="utf-8")
self.target = _TARGET_FIXTURE.read_text(encoding="utf-8")
self.exit = _EXIT_FIXTURE.read_text(encoding="utf-8")
self.calls: list[tuple[object, ...]] = []
self.click_timeout = click_timeout
def app_info(self, package: str) -> dict[str, str]:
self.calls.append(("app_info", package))
return {"versionName": "8.17.0"}
def window_size(self) -> tuple[int, int]:
self.calls.append(("window_size",))
return (1080, 2376)
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> object:
self.calls.append(("jsonrpc", method, params, timeout))
if method == "dumpWindowHierarchy":
return self.current
if method == "click":
self.current = self.target
if self.click_timeout:
raise TimeoutError("may have been delivered")
return None
if method == "takeScreenshot":
return _png_base64()
if method == "pressKey":
self.current = self.exit
return None
raise AssertionError(method)
class QuantityGate2RunnerTests(unittest.TestCase):
def test_runner_publishes_gate2_evidence_then_one_safe_exit(self) -> None:
with TemporaryDirectory() as temporary:
base = Path(temporary)
gate1_path = base / "gate1.png"
gate1_path.write_bytes(base64.b64decode(_png_base64()))
target = base / "gate2"
raw = FakeRawQuantityDevice()
result = QuantityGate2Runner(
FakeAdbClient(),
lambda serial: raw,
FakeForegroundReader(),
timeout_seconds=0.1,
).run(SERIAL, EXPECTED_GOODS_ID, _gate1(gate1_path), 2, "40.00", target)
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
self.assertEqual(methods.count("click"), 1)
self.assertEqual(methods.count("pressKey"), 1)
self.assertEqual(methods.count("takeScreenshot"), 1)
self.assertEqual(result.observation.gate2_panel_total_price, "32.76")
self.assertEqual(manifest["prices"]["gate1_unit_price"], "12.88")
self.assertEqual(manifest["prices"]["gate2_panel_total_price"], "32.76")
self.assertEqual(manifest["prices"]["max_total_price"], "40.00")
self.assertEqual(manifest["action_audit"]["increment_attempts"], 1)
self.assertEqual(manifest["action_audit"]["back_attempts"], 1)
self.assertEqual(manifest["safe_exit"], "completed")
self.assertEqual(manifest["review_status"], "human_review_required")
serialized = result.manifest_path.read_text(encoding="utf-8")
self.assertNotIn(SERIAL, serialized)
self.assertNotIn(str(gate1_path), serialized)
def test_over_cap_attempts_one_safe_exit_and_publishes_nothing(self) -> None:
with TemporaryDirectory() as temporary:
base = Path(temporary)
gate1_path = base / "gate1.png"
gate1_path.write_bytes(base64.b64decode(_png_base64()))
raw = FakeRawQuantityDevice()
target = base / "gate2"
with self.assertRaises(QuantityGate2OverCapError):
QuantityGate2Runner(
FakeAdbClient(), lambda serial: raw, FakeForegroundReader(), timeout_seconds=0.1
).run(SERIAL, EXPECTED_GOODS_ID, _gate1(gate1_path), 2, "30.00", target)
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
self.assertEqual(methods.count("click"), 1)
self.assertEqual(methods.count("pressKey"), 1)
self.assertFalse(target.exists())
self.assertEqual(list(base.glob(".gate2.staging-*")), [])
def test_ambiguous_increment_is_never_retried_or_followed_by_back(self) -> None:
with TemporaryDirectory() as temporary:
base = Path(temporary)
gate1_path = base / "gate1.png"
gate1_path.write_bytes(base64.b64decode(_png_base64()))
raw = FakeRawQuantityDevice(click_timeout=True)
with self.assertRaises(QuantityGate2Error):
QuantityGate2Runner(
FakeAdbClient(), lambda serial: raw, FakeForegroundReader(), timeout_seconds=0.1
).run(SERIAL, EXPECTED_GOODS_ID, _gate1(gate1_path), 2, "40.00", base / "gate2")
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
self.assertEqual(methods.count("click"), 1)
self.assertEqual(methods.count("pressKey"), 0)
class QuantityGate2ProductionStaticBoundaryTests(unittest.TestCase):
def test_protocol_has_only_named_t105_actions(self) -> None:
public = {name for name in QuantityGate2Device.__dict__ if not name.startswith("_")}
self.assertEqual(
public,
{
"app_info",
"current_foreground",
"display_size",
"dump_window_hierarchy",
"increment_quantity_once",
"capture_screenshot",
"leave_sku_panel_once",
},
)
def test_production_sources_have_no_old_gate2_field_or_forbidden_capability(self) -> None:
paths = (
CLIENT_ROOT / "src/cmbuyer_client/pdd/quantity_gate2.py",
CLIENT_ROOT / "src/cmbuyer_client/pdd/quantity_gate2_runner.py",
CLIENT_ROOT / "scripts/run_t105_quantity_gate2.py",
)
forbidden_attributes = {
"go_to_order_confirm",
"submit_order_once",
"start_pdd_view_intent",
"pay",
"swipe",
"set_text",
"send_keys",
}
for path in paths:
source = path.read_text(encoding="utf-8")
tree = ast.parse(source)
self.assertNotIn("gate2_unit_price", source)
self.assertFalse(
{
node.attr
for node in ast.walk(tree)
if isinstance(node, ast.Attribute) and node.attr in forbidden_attributes
}
)
quantity_tree = ast.parse(paths[0].read_text(encoding="utf-8"))
self.assertFalse(any(isinstance(node, ast.Mult) for node in ast.walk(quantity_tree)))
def test_t103_and_t104_still_do_not_import_t105(self) -> None:
for relative in (
"src/cmbuyer_client/pdd/sku_selection.py",
"src/cmbuyer_client/pdd/sku_selection_runner.py",
):
self.assertNotIn("quantity_gate2", (CLIENT_ROOT / relative).read_text(encoding="utf-8"))
def test_live_cli_fixes_the_human_review_case_and_redacts_runtime_failure(self) -> None:
script = _load_run_script()
with TemporaryDirectory() as temporary:
screenshot = Path(temporary) / "gate1.png"
screenshot.write_bytes(base64.b64decode(_png_base64()))
arguments = script.parse_arguments( # type: ignore[attr-defined]
[
"--serial", SERIAL,
"--goods-id", EXPECTED_GOODS_ID,
"--color", "黑色CHA(纯棉)",
"--size", "M(建议100-115)",
"--target-quantity", "2",
"--gate1-unit-price", "12.88",
"--gate1-screenshot", str(screenshot),
"--gate1-captured-at", "2026-08-06T00:53:00.023935+00:00",
"--max-total-price", "40.00",
"--output-dir", str(Path(temporary) / "gate2"),
]
)
script.validate_arguments(arguments) # type: ignore[attr-defined]
stderr = StringIO()
fake_runner = unittest.mock.Mock()
fake_runner.run.side_effect = QuantityGate2Error(f"secret {SERIAL} {temporary}")
with (
patch.object(script, "QuantityGate2Runner", return_value=fake_runner),
patch.dict(sys.modules, {"adbutils": unittest.mock.Mock(), "uiautomator2": unittest.mock.Mock()}),
redirect_stderr(stderr),
):
result = script.main( # type: ignore[attr-defined]
[
"--serial", SERIAL,
"--goods-id", EXPECTED_GOODS_ID,
"--color", "黑色CHA(纯棉)",
"--size", "M(建议100-115)",
"--target-quantity", "2",
"--gate1-unit-price", "12.88",
"--gate1-screenshot", str(screenshot),
"--gate1-captured-at", "2026-08-06T00:53:00.023935+00:00",
"--max-total-price", "40.00",
"--output-dir", str(Path(temporary) / "gate2"),
]
)
self.assertEqual(result, 1)
self.assertNotIn(SERIAL, stderr.getvalue())
self.assertNotIn(temporary, stderr.getvalue())
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()