feat(client): verify T-105 quantity Gate2
This commit is contained in:
@@ -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>
|
||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user