feat(client): add T-107 Gate3 dry-run observer
This commit is contained in:
@@ -0,0 +1,138 @@
|
|||||||
|
"""运行 T-107 最终面板 Gate3 只读观察与一次安全返回。"""
|
||||||
|
|
||||||
|
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.final_submit_panel import FinalSubmitPanelError
|
||||||
|
from cmbuyer_client.pdd.final_submit_panel_runner import FinalSubmitPanelRunner
|
||||||
|
from cmbuyer_client.pdd.quantity_gate2 import (
|
||||||
|
EXPECTED_GATE1_UNIT_PRICE,
|
||||||
|
EXPECTED_GOODS_ID,
|
||||||
|
TASK_COLOR,
|
||||||
|
TASK_SIZE,
|
||||||
|
Gate2Observation,
|
||||||
|
)
|
||||||
|
from cmbuyer_client.pdd.quantity_gate2_spike import Android16TopResumedForegroundReader
|
||||||
|
|
||||||
|
|
||||||
|
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="运行 T-107 围栏前最终面板只读 dry-run。")
|
||||||
|
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("--quantity", required=True, type=int)
|
||||||
|
parser.add_argument("--gate1-unit-price", required=True)
|
||||||
|
parser.add_argument("--gate2-panel-total-price", required=True)
|
||||||
|
parser.add_argument("--gate2-screenshot", required=True, type=Path)
|
||||||
|
parser.add_argument("--gate2-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-107 已批准目标。")
|
||||||
|
if arguments.color != TASK_COLOR or arguments.size != TASK_SIZE:
|
||||||
|
raise ValueError("颜色或尺码不是 T-107 已批准目标。")
|
||||||
|
if type(arguments.quantity) is not int or arguments.quantity != 2:
|
||||||
|
raise ValueError("本次真机验收只批准 --quantity 2。")
|
||||||
|
if arguments.gate1_unit_price != EXPECTED_GATE1_UNIT_PRICE:
|
||||||
|
raise ValueError("--gate1-unit-price 与已确认 Gate1 不一致。")
|
||||||
|
if arguments.gate2_panel_total_price != "32.76":
|
||||||
|
raise ValueError("--gate2-panel-total-price 与 T-106 已确认值不一致。")
|
||||||
|
if not isinstance(arguments.gate2_screenshot, Path) or not arguments.gate2_screenshot.is_file():
|
||||||
|
raise ValueError("--gate2-screenshot 必须是现有显式文件。")
|
||||||
|
try:
|
||||||
|
captured_at = datetime.fromisoformat(arguments.gate2_captured_at)
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise ValueError("--gate2-captured-at 必须是带时区 ISO 时间。") from error
|
||||||
|
if captured_at.utcoffset() is None:
|
||||||
|
raise ValueError("--gate2-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 = FinalSubmitPanelRunner(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
gate2 = Gate2Observation(
|
||||||
|
requested_color=arguments.color,
|
||||||
|
requested_size=arguments.size,
|
||||||
|
actual_color=arguments.color,
|
||||||
|
actual_size=arguments.size,
|
||||||
|
requested_quantity=arguments.quantity,
|
||||||
|
quantity_read=arguments.quantity,
|
||||||
|
gate1_unit_price=arguments.gate1_unit_price,
|
||||||
|
gate2_panel_total_price=arguments.gate2_panel_total_price,
|
||||||
|
max_total_price=arguments.max_total_price,
|
||||||
|
screenshot_path=arguments.gate2_screenshot,
|
||||||
|
captured_at=captured_at,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
runner.run(
|
||||||
|
arguments.serial,
|
||||||
|
arguments.goods_id,
|
||||||
|
gate2,
|
||||||
|
arguments.output_dir,
|
||||||
|
)
|
||||||
|
except (DeviceConnectionError, FinalSubmitPanelError, OSError):
|
||||||
|
# 不回显第三方异常、serial、本机路径或页面正文。
|
||||||
|
print("T-107 最终面板 dry-run 失败:已停止,未发布证据目录。", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("T-107 最终面板 dry-run 完成。")
|
||||||
|
print("人工复核:Gate2/Gate3、最终控件唯一、一次安全返回,且未创建订单或进入付款。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
"""T-107:合并式最终提交面板的纯 XML Gate3 observer。
|
||||||
|
|
||||||
|
本模块不持有设备,也不返回节点、坐标、selector 或可操作对象。页面结构只来自
|
||||||
|
T-106 在拼多多 8.17.0 / goods_id 937122477375 上由人确认的两态证据。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from typing import Iterable
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
|
from ..core.errors import ValidationError
|
||||||
|
from ..core.validation import require_money
|
||||||
|
from ..device.baseline import PDD_PACKAGE
|
||||||
|
from .quantity_gate2 import (
|
||||||
|
TASK_COLOR,
|
||||||
|
TASK_SIZE,
|
||||||
|
UI_COLOR,
|
||||||
|
UI_SIZE,
|
||||||
|
Gate2Observation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
FINAL_PANEL_QUANTITY = 2
|
||||||
|
|
||||||
|
_PDD_ID = "com.xunmeng.pinduoduo:id/pdd"
|
||||||
|
_QUANTITY_ID = "com.xunmeng.pinduoduo:id/gnl"
|
||||||
|
_TITLE_ID = "com.xunmeng.pinduoduo:id/tv_title"
|
||||||
|
_PANEL_BOUNDS = "[0,551][1080,938]"
|
||||||
|
_PRICE_FRAME_BOUNDS = "[396,575][1053,647]"
|
||||||
|
_PRICE_ROW_BOUNDS = "[396,575][740,647]"
|
||||||
|
_PRICE_TEXT_BOUNDS = "[396,580][722,647]"
|
||||||
|
_SUMMARY_BOUNDS = "[396,659][1053,721]"
|
||||||
|
_QUANTITY_BOUNDS = "[396,827][645,902]"
|
||||||
|
_MINUS_BOUNDS = "[396,827][474,902]"
|
||||||
|
_VALUE_BOUNDS = "[480,827][561,902]"
|
||||||
|
_PLUS_BOUNDS = "[567,827][645,902]"
|
||||||
|
_SUBMIT_TEXT_BOUNDS = "[366,2225][714,2284]"
|
||||||
|
_SUBMIT_TEXT_PARENT_BOUNDS = "[354,2181][726,2328]"
|
||||||
|
_SUBMIT_ACTION_BOUNDS = "[0,2181][1080,2328]"
|
||||||
|
_EXPECTED_SUMMARY = f"已选: {UI_COLOR} {UI_SIZE}"
|
||||||
|
|
||||||
|
_GATE2_TEXT = re.compile(r"^快卖完 ¥(?P<amount>(?:0|[1-9]\d*)\.\d{2})$")
|
||||||
|
_GATE3_TEXT = re.compile(r"^提交订单 ¥(?P<amount>(?:0|[1-9]\d*)\.\d{2})$")
|
||||||
|
|
||||||
|
_PRODUCT_TITLE = "2026年新款高档重工潮流烫钻中长款T恤显瘦宽松上衣淡人穿搭"
|
||||||
|
_PRODUCT_TITLE_BOUNDS = "[36,1571][1044,1689]"
|
||||||
|
_PRODUCT_TITLE_LINES = (
|
||||||
|
("2026年新款高档重工潮流烫钻中长款T恤显瘦宽松", "[36,1571][1023,1624]"),
|
||||||
|
("上衣淡人穿搭", "[36,1636][306,1689]"),
|
||||||
|
)
|
||||||
|
_ENTRY_DESC = "快要抢光¥12.88"
|
||||||
|
_ENTRY_BOUNDS = "[446,2166][1080,2328]"
|
||||||
|
_ENTRY_CHILDREN = (
|
||||||
|
("快要抢光 ¥ 12.88", "[688,2184][1042,2253]"),
|
||||||
|
("免拼购买", "[688,2256][856,2305]"),
|
||||||
|
)
|
||||||
|
_DANGEROUS_RETURN_TERMS = ("提交订单", "确认订单", "立即支付", "去支付", "付款")
|
||||||
|
|
||||||
|
|
||||||
|
class FinalSubmitPanelError(RuntimeError):
|
||||||
|
"""最终面板或 Gate3 判据不成立时的脱敏安全停止。"""
|
||||||
|
|
||||||
|
|
||||||
|
class FinalSubmitPanelOverCapError(FinalSubmitPanelError):
|
||||||
|
"""Gate2/Gate3 金额超过管理员授权最高总价。"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Gate3Observation:
|
||||||
|
requested_color: str
|
||||||
|
requested_size: str
|
||||||
|
actual_color: str
|
||||||
|
actual_size: str
|
||||||
|
requested_quantity: int
|
||||||
|
quantity_read: int
|
||||||
|
gate2_panel_total_price: str
|
||||||
|
gate3_submit_amount: str
|
||||||
|
max_total_price: str
|
||||||
|
submit_control_text: str
|
||||||
|
submit_control_match_count: int
|
||||||
|
submit_control_enabled: bool
|
||||||
|
nearest_clickable_ancestor_unique: bool
|
||||||
|
screenshot_path: Path
|
||||||
|
captured_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
||||||
|
gate2_amount: str
|
||||||
|
gate3_amount: str
|
||||||
|
submit_text: str
|
||||||
|
submit_match_count: int
|
||||||
|
submit_enabled: bool
|
||||||
|
nearest_clickable_ancestor_unique: bool
|
||||||
|
|
||||||
|
|
||||||
|
def observe_gate3(
|
||||||
|
raw_hierarchy: str,
|
||||||
|
gate2: Gate2Observation,
|
||||||
|
screenshot_path: Path,
|
||||||
|
captured_at: datetime,
|
||||||
|
) -> Gate3Observation:
|
||||||
|
"""从一棵已读取 XML 中构造不可操作的 Gate3 摘要。"""
|
||||||
|
|
||||||
|
_validate_gate2(gate2)
|
||||||
|
if not isinstance(screenshot_path, Path) or not screenshot_path.name:
|
||||||
|
raise FinalSubmitPanelError("Gate3 截图路径无效,已停止操作。")
|
||||||
|
if not isinstance(captured_at, datetime) or captured_at.utcoffset() is None:
|
||||||
|
raise FinalSubmitPanelError("Gate3 采集时间必须带时区,已停止操作。")
|
||||||
|
|
||||||
|
verified = _verified_final_panel(_parse_nodes(raw_hierarchy))
|
||||||
|
if verified.gate2_amount != gate2.gate2_panel_total_price:
|
||||||
|
raise FinalSubmitPanelError("当前面板 Gate2 顶部总额与可信观察不一致,已停止操作。")
|
||||||
|
if verified.gate3_amount != verified.gate2_amount:
|
||||||
|
raise FinalSubmitPanelError("Gate3 最终控件金额与 Gate2 顶部总额不一致,已停止操作。")
|
||||||
|
if Decimal(verified.gate3_amount) > Decimal(gate2.max_total_price):
|
||||||
|
raise FinalSubmitPanelOverCapError("Gate3 金额超过授权最高总价,已停止操作。")
|
||||||
|
|
||||||
|
return Gate3Observation(
|
||||||
|
requested_color=gate2.requested_color,
|
||||||
|
requested_size=gate2.requested_size,
|
||||||
|
actual_color=gate2.actual_color,
|
||||||
|
actual_size=gate2.actual_size,
|
||||||
|
requested_quantity=gate2.requested_quantity,
|
||||||
|
quantity_read=gate2.quantity_read,
|
||||||
|
gate2_panel_total_price=verified.gate2_amount,
|
||||||
|
gate3_submit_amount=verified.gate3_amount,
|
||||||
|
max_total_price=gate2.max_total_price,
|
||||||
|
submit_control_text=verified.submit_text,
|
||||||
|
submit_control_match_count=verified.submit_match_count,
|
||||||
|
submit_control_enabled=verified.submit_enabled,
|
||||||
|
nearest_clickable_ancestor_unique=verified.nearest_clickable_ancestor_unique,
|
||||||
|
screenshot_path=screenshot_path,
|
||||||
|
captured_at=captured_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def returned_product_projection(raw_hierarchy: str) -> tuple[object, ...]:
|
||||||
|
"""验证 T-106 一次 Back 后的同商品安全页;入口数字只作页面身份,不读价。"""
|
||||||
|
|
||||||
|
nodes = _parse_nodes(raw_hierarchy)
|
||||||
|
if any(
|
||||||
|
_live_clickable(node)
|
||||||
|
and any(term in value for value in (node.text, node.desc) for term in _DANGEROUS_RETURN_TERMS)
|
||||||
|
for node in nodes
|
||||||
|
):
|
||||||
|
raise FinalSubmitPanelError("返回后出现危险动作语义,不能确认安全退出。")
|
||||||
|
|
||||||
|
title = _one(
|
||||||
|
node
|
||||||
|
for node in nodes
|
||||||
|
if _exact(
|
||||||
|
node,
|
||||||
|
"android.view.ViewGroup",
|
||||||
|
_PRODUCT_TITLE_BOUNDS,
|
||||||
|
resource_id=_TITLE_ID,
|
||||||
|
clickable="true",
|
||||||
|
)
|
||||||
|
and node.element.get("long-clickable") == "true"
|
||||||
|
and not node.text
|
||||||
|
and node.desc == _PRODUCT_TITLE
|
||||||
|
)
|
||||||
|
title_children = _direct_children(title)
|
||||||
|
if len(title_children) != len(_PRODUCT_TITLE_LINES) or any(
|
||||||
|
not _exact_text(child, text, bounds)
|
||||||
|
for child, (text, bounds) in zip(title_children, _PRODUCT_TITLE_LINES, strict=True)
|
||||||
|
):
|
||||||
|
raise FinalSubmitPanelError("返回后同商品标题子结构漂移。")
|
||||||
|
|
||||||
|
entry = _one(
|
||||||
|
node
|
||||||
|
for node in nodes
|
||||||
|
if _exact(
|
||||||
|
node,
|
||||||
|
"android.view.ViewGroup",
|
||||||
|
_ENTRY_BOUNDS,
|
||||||
|
resource_id=_PDD_ID,
|
||||||
|
clickable="true",
|
||||||
|
)
|
||||||
|
and not node.text
|
||||||
|
and node.desc == _ENTRY_DESC
|
||||||
|
)
|
||||||
|
all_entry_children = _direct_children(entry)
|
||||||
|
entry_children = [child for child in all_entry_children if child.text]
|
||||||
|
if len(entry_children) != len(_ENTRY_CHILDREN) or any(
|
||||||
|
not _exact_text(child, text, bounds, resource_id=_PDD_ID)
|
||||||
|
for child, (text, bounds) in zip(entry_children, _ENTRY_CHILDREN, strict=True)
|
||||||
|
) or any(
|
||||||
|
child not in entry_children
|
||||||
|
and (child.text or child.desc or _live_clickable(child))
|
||||||
|
for child in all_entry_children
|
||||||
|
):
|
||||||
|
raise FinalSubmitPanelError("返回后商品规格入口结构漂移。")
|
||||||
|
|
||||||
|
return (
|
||||||
|
"final_submit_panel_exit_8_17_0",
|
||||||
|
_projection(title),
|
||||||
|
tuple(_projection(child) for child in title_children),
|
||||||
|
_projection(entry),
|
||||||
|
tuple(_projection(child) for child in entry_children),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_gate2(gate2: object) -> None:
|
||||||
|
if not isinstance(gate2, Gate2Observation):
|
||||||
|
raise FinalSubmitPanelError("缺少可信 Gate2Observation,已停止操作。")
|
||||||
|
if (
|
||||||
|
gate2.requested_color != TASK_COLOR
|
||||||
|
or gate2.actual_color != TASK_COLOR
|
||||||
|
or gate2.requested_size != TASK_SIZE
|
||||||
|
or gate2.actual_size != TASK_SIZE
|
||||||
|
):
|
||||||
|
raise FinalSubmitPanelError("Gate2 规格不是 T-107 已取证目标,已停止操作。")
|
||||||
|
if (
|
||||||
|
type(gate2.requested_quantity) is not int
|
||||||
|
or type(gate2.quantity_read) is not int
|
||||||
|
or gate2.requested_quantity != FINAL_PANEL_QUANTITY
|
||||||
|
or gate2.quantity_read != FINAL_PANEL_QUANTITY
|
||||||
|
):
|
||||||
|
raise FinalSubmitPanelError("Gate2 数量不是 T-107 已取证值,已停止操作。")
|
||||||
|
_money(gate2.gate1_unit_price)
|
||||||
|
gate2_amount = _money(gate2.gate2_panel_total_price)
|
||||||
|
maximum = _money(gate2.max_total_price)
|
||||||
|
if not isinstance(gate2.screenshot_path, Path) or not gate2.screenshot_path.name:
|
||||||
|
raise FinalSubmitPanelError("Gate2 截图引用无效,已停止操作。")
|
||||||
|
if not isinstance(gate2.captured_at, datetime) or gate2.captured_at.utcoffset() is None:
|
||||||
|
raise FinalSubmitPanelError("Gate2 采集时间必须带时区,已停止操作。")
|
||||||
|
if Decimal(gate2_amount) > Decimal(maximum):
|
||||||
|
raise FinalSubmitPanelOverCapError("Gate2 顶部总额超过授权最高总价,已停止操作。")
|
||||||
|
|
||||||
|
|
||||||
|
def _verified_final_panel(nodes: list[_Node]) -> _VerifiedPanel:
|
||||||
|
panel = _one(
|
||||||
|
node
|
||||||
|
for node in nodes
|
||||||
|
if _exact(node, "android.view.ViewGroup", _PANEL_BOUNDS, resource_id=_PDD_ID)
|
||||||
|
)
|
||||||
|
price_frame = _one(
|
||||||
|
node
|
||||||
|
for node in nodes
|
||||||
|
if node.parent is panel
|
||||||
|
and _exact(node, "android.widget.FrameLayout", _PRICE_FRAME_BOUNDS, resource_id=_PDD_ID)
|
||||||
|
)
|
||||||
|
price_row = _one(
|
||||||
|
node
|
||||||
|
for node in nodes
|
||||||
|
if node.parent is price_frame
|
||||||
|
and _exact(node, "android.widget.LinearLayout", _PRICE_ROW_BOUNDS, resource_id=_PDD_ID)
|
||||||
|
)
|
||||||
|
price = _one(
|
||||||
|
node
|
||||||
|
for node in nodes
|
||||||
|
if node.parent is price_row
|
||||||
|
and _exact_text_node(node, "android.widget.TextView", _PRICE_TEXT_BOUNDS, resource_id=_PDD_ID)
|
||||||
|
and _GATE2_TEXT.fullmatch(node.text) is not None
|
||||||
|
)
|
||||||
|
gate2_match = _GATE2_TEXT.fullmatch(price.text)
|
||||||
|
if gate2_match is None:
|
||||||
|
raise FinalSubmitPanelError("最终面板 Gate2 顶部金额不可读。")
|
||||||
|
gate2_amount = _money(gate2_match.group("amount"))
|
||||||
|
|
||||||
|
_one(
|
||||||
|
node
|
||||||
|
for node in nodes
|
||||||
|
if node.parent is panel
|
||||||
|
and _exact_text_node(node, "android.widget.TextView", _SUMMARY_BOUNDS, resource_id=_PDD_ID)
|
||||||
|
and node.text == _EXPECTED_SUMMARY
|
||||||
|
)
|
||||||
|
quantity_outer = _one(
|
||||||
|
node
|
||||||
|
for node in nodes
|
||||||
|
if node.parent is panel
|
||||||
|
and _exact(node, "android.widget.LinearLayout", _QUANTITY_BOUNDS, resource_id=_QUANTITY_ID)
|
||||||
|
)
|
||||||
|
quantity_inner = _one(
|
||||||
|
node
|
||||||
|
for node in nodes
|
||||||
|
if node.parent is quantity_outer
|
||||||
|
and _exact(node, "android.widget.LinearLayout", _QUANTITY_BOUNDS, resource_id="")
|
||||||
|
)
|
||||||
|
quantity_children = _direct_children(quantity_inner)
|
||||||
|
if len(quantity_children) != 3:
|
||||||
|
raise FinalSubmitPanelError("最终面板数量子结构漂移。")
|
||||||
|
_one(
|
||||||
|
node
|
||||||
|
for node in quantity_children
|
||||||
|
if _exact(node, "android.widget.ImageView", _MINUS_BOUNDS, resource_id=_PDD_ID, clickable="true")
|
||||||
|
and not node.text
|
||||||
|
and node.desc == "减少数量"
|
||||||
|
)
|
||||||
|
_one(
|
||||||
|
node
|
||||||
|
for node in quantity_children
|
||||||
|
if _exact(node, "android.widget.EditText", _VALUE_BOUNDS, resource_id=_PDD_ID, clickable="true")
|
||||||
|
and node.text == str(FINAL_PANEL_QUANTITY)
|
||||||
|
and not node.desc
|
||||||
|
)
|
||||||
|
_one(
|
||||||
|
node
|
||||||
|
for node in quantity_children
|
||||||
|
if _exact(node, "android.widget.ImageView", _PLUS_BOUNDS, resource_id=_PDD_ID, clickable="true")
|
||||||
|
and not node.text
|
||||||
|
and node.desc == "增加数量"
|
||||||
|
)
|
||||||
|
|
||||||
|
submit_like = [
|
||||||
|
node
|
||||||
|
for node in nodes
|
||||||
|
if "提交订单" in node.text or "提交订单" in node.desc
|
||||||
|
]
|
||||||
|
structured = [node for node in submit_like if _GATE3_TEXT.fullmatch(node.text) is not None and not node.desc]
|
||||||
|
if len(submit_like) != 1 or len(structured) != 1:
|
||||||
|
raise FinalSubmitPanelError("最终提交完整结构化文本匹配数不是 1,已停止操作。")
|
||||||
|
submit = structured[0]
|
||||||
|
if not _exact_text_node(submit, "android.widget.TextView", _SUBMIT_TEXT_BOUNDS, resource_id=""):
|
||||||
|
raise FinalSubmitPanelError("最终提交文本节点结构漂移,已停止操作。")
|
||||||
|
submit_parent = submit.parent
|
||||||
|
if (
|
||||||
|
submit_parent is None
|
||||||
|
or not _exact(
|
||||||
|
submit_parent,
|
||||||
|
"android.widget.LinearLayout",
|
||||||
|
_SUBMIT_TEXT_PARENT_BOUNDS,
|
||||||
|
resource_id="",
|
||||||
|
)
|
||||||
|
or _direct_children(submit_parent) != [submit]
|
||||||
|
):
|
||||||
|
raise FinalSubmitPanelError("最终提交文本父结构漂移,已停止操作。")
|
||||||
|
nearest = _nearest_clickable_ancestor(submit)
|
||||||
|
if nearest is None or not _exact(
|
||||||
|
nearest,
|
||||||
|
"android.widget.FrameLayout",
|
||||||
|
_SUBMIT_ACTION_BOUNDS,
|
||||||
|
resource_id=_PDD_ID,
|
||||||
|
clickable="true",
|
||||||
|
):
|
||||||
|
raise FinalSubmitPanelError("最终提交文本的最近可点击祖先不唯一或结构漂移。")
|
||||||
|
gate3_match = _GATE3_TEXT.fullmatch(submit.text)
|
||||||
|
if gate3_match is None:
|
||||||
|
raise FinalSubmitPanelError("Gate3 最终控件金额不可读。")
|
||||||
|
|
||||||
|
return _VerifiedPanel(
|
||||||
|
gate2_amount=gate2_amount,
|
||||||
|
gate3_amount=_money(gate3_match.group("amount")),
|
||||||
|
submit_text=submit.text,
|
||||||
|
submit_match_count=len(structured),
|
||||||
|
submit_enabled=submit.element.get("enabled") == "true" and nearest.element.get("enabled") == "true",
|
||||||
|
nearest_clickable_ancestor_unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_nodes(raw: object) -> list[_Node]:
|
||||||
|
if not isinstance(raw, str) or not raw:
|
||||||
|
raise FinalSubmitPanelError("节点树读取失败,已停止操作。")
|
||||||
|
try:
|
||||||
|
root = ElementTree.fromstring(raw)
|
||||||
|
except ElementTree.ParseError as error:
|
||||||
|
raise FinalSubmitPanelError("节点树格式无效,已停止操作。") from error
|
||||||
|
if root.tag != "hierarchy":
|
||||||
|
raise FinalSubmitPanelError("节点树根节点无效,已停止操作。")
|
||||||
|
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 _money(value: object) -> str:
|
||||||
|
try:
|
||||||
|
return require_money(value, "invalid_money")
|
||||||
|
except ValidationError as error:
|
||||||
|
raise FinalSubmitPanelError("金额不是规范十进制字符串,已停止操作。") from error
|
||||||
|
|
||||||
|
|
||||||
|
def _one(nodes: Iterable[_Node]) -> _Node:
|
||||||
|
matches = list(nodes)
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise FinalSubmitPanelError("证据绑定页面角色不唯一,已停止操作。")
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _exact(
|
||||||
|
node: _Node,
|
||||||
|
class_name: str,
|
||||||
|
bounds: str,
|
||||||
|
*,
|
||||||
|
resource_id: str,
|
||||||
|
clickable: str = "false",
|
||||||
|
) -> bool:
|
||||||
|
return (
|
||||||
|
node.element.get("package") == PDD_PACKAGE
|
||||||
|
and node.element.get("class") == class_name
|
||||||
|
and node.element.get("resource-id", "") == resource_id
|
||||||
|
and node.bounds == bounds
|
||||||
|
and node.element.get("clickable") == clickable
|
||||||
|
and node.element.get("enabled") == "true"
|
||||||
|
and node.element.get("visible-to-user") == "true"
|
||||||
|
and node.element.get("selected") == "false"
|
||||||
|
and node.element.get("scrollable") == "false"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _exact_text_node(node: _Node, class_name: str, bounds: str, *, resource_id: str) -> bool:
|
||||||
|
return (
|
||||||
|
_exact(node, class_name, bounds, resource_id=resource_id)
|
||||||
|
and not node.desc
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _exact_text(node: _Node, text: str, bounds: str, *, resource_id: str = "") -> bool:
|
||||||
|
return (
|
||||||
|
_exact_text_node(node, "android.widget.TextView", bounds, resource_id=resource_id)
|
||||||
|
and node.text == text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _direct_children(node: _Node) -> list[_Node]:
|
||||||
|
return [_Node(child, node) for child in node.element if child.tag == "node"]
|
||||||
|
|
||||||
|
|
||||||
|
def _nearest_clickable_ancestor(node: _Node) -> _Node | None:
|
||||||
|
current = node.parent
|
||||||
|
while current is not None:
|
||||||
|
if current.element.get("clickable") == "true":
|
||||||
|
return current
|
||||||
|
current = current.parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _live_clickable(node: _Node) -> bool:
|
||||||
|
return (
|
||||||
|
node.element.get("clickable") == "true"
|
||||||
|
and node.element.get("enabled") == "true"
|
||||||
|
and node.element.get("visible-to-user") == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _projection(node: _Node) -> tuple[str, ...]:
|
||||||
|
return (
|
||||||
|
node.element.tag,
|
||||||
|
node.element.get("package", ""),
|
||||||
|
node.element.get("class", ""),
|
||||||
|
node.element.get("resource-id", ""),
|
||||||
|
node.bounds,
|
||||||
|
node.element.get("clickable", ""),
|
||||||
|
node.element.get("enabled", ""),
|
||||||
|
node.element.get("visible-to-user", ""),
|
||||||
|
node.text,
|
||||||
|
node.desc,
|
||||||
|
)
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
"""T-107 真机边界:零页面控件动作观察 Gate3,然后只尝试一次系统 Back。"""
|
||||||
|
|
||||||
|
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, sleep
|
||||||
|
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 .final_submit_panel import (
|
||||||
|
FinalSubmitPanelError,
|
||||||
|
Gate3Observation,
|
||||||
|
observe_gate3,
|
||||||
|
returned_product_projection,
|
||||||
|
)
|
||||||
|
from .product_open import EXPECTED_PDD_VERSION
|
||||||
|
from .quantity_gate2 import (
|
||||||
|
EXPECTED_ANDROID_VERSION,
|
||||||
|
EXPECTED_DEVICE_MODEL,
|
||||||
|
EXPECTED_GOODS_ID,
|
||||||
|
EXPECTED_SCREEN_SIZE,
|
||||||
|
Gate2Observation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FinalSubmitPanelTimeoutError(FinalSubmitPanelError):
|
||||||
|
"""设备读取或一次 Back 后置条件等待超时。"""
|
||||||
|
|
||||||
|
|
||||||
|
class FinalSubmitPanelAdapterError(FinalSubmitPanelError):
|
||||||
|
"""第三方设备接口失败后的脱敏映射。"""
|
||||||
|
|
||||||
|
|
||||||
|
class ForegroundReader(Protocol):
|
||||||
|
def read(self, serial: str) -> dict[str, str]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class FinalSubmitPanelDevice(Protocol):
|
||||||
|
"""最终面板的窄设备能力;没有任何页面控件动作。"""
|
||||||
|
|
||||||
|
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 capture_screenshot(self) -> str: ...
|
||||||
|
|
||||||
|
def leave_final_submit_panel_once(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FinalSubmitPanelRunResult:
|
||||||
|
output_directory: Path
|
||||||
|
screenshot_path: Path
|
||||||
|
manifest_path: Path
|
||||||
|
observation: Gate3Observation
|
||||||
|
|
||||||
|
|
||||||
|
class FinalSubmitPanelFlow:
|
||||||
|
"""读取同一最终面板并在成功观察后执行一次安全返回。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
device: FinalSubmitPanelDevice,
|
||||||
|
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._observation: Gate3Observation | None = None
|
||||||
|
self._gate2: Gate2Observation | None = None
|
||||||
|
self._terminal = False
|
||||||
|
self._exited = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def exited(self) -> bool:
|
||||||
|
return self._exited
|
||||||
|
|
||||||
|
def require_ready_for_read(self) -> None:
|
||||||
|
self._require_active()
|
||||||
|
self._require_environment()
|
||||||
|
|
||||||
|
def observe(
|
||||||
|
self,
|
||||||
|
gate2: Gate2Observation,
|
||||||
|
screenshot_path: Path,
|
||||||
|
captured_at: datetime,
|
||||||
|
) -> Gate3Observation:
|
||||||
|
self._require_active()
|
||||||
|
try:
|
||||||
|
self._require_environment()
|
||||||
|
observation = observe_gate3(
|
||||||
|
self._read_hierarchy(),
|
||||||
|
gate2,
|
||||||
|
screenshot_path,
|
||||||
|
captured_at,
|
||||||
|
)
|
||||||
|
self._require_environment()
|
||||||
|
except BaseException:
|
||||||
|
self._terminal = True
|
||||||
|
raise
|
||||||
|
self._gate2 = gate2
|
||||||
|
self._observation = observation
|
||||||
|
return observation
|
||||||
|
|
||||||
|
def exit_final_submit_panel_safely(self) -> None:
|
||||||
|
self._require_active()
|
||||||
|
if self._observation is None or self._gate2 is None:
|
||||||
|
raise FinalSubmitPanelError("没有可信 Gate3 观察,拒绝执行安全返回。")
|
||||||
|
try:
|
||||||
|
self._require_environment()
|
||||||
|
current = observe_gate3(
|
||||||
|
self._read_hierarchy(),
|
||||||
|
self._gate2,
|
||||||
|
self._observation.screenshot_path,
|
||||||
|
self._observation.captured_at,
|
||||||
|
)
|
||||||
|
self._require_environment()
|
||||||
|
if current != self._observation:
|
||||||
|
raise FinalSubmitPanelError("安全返回前最终面板事实漂移,已停止操作。")
|
||||||
|
self._device.leave_final_submit_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 = returned_product_projection(raw)
|
||||||
|
except FinalSubmitPanelError:
|
||||||
|
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 FinalSubmitPanelTimeoutError("一次 Back 后未达到连续稳定同商品判据,未重试返回。")
|
||||||
|
self._sleep(min(self._poll, remaining))
|
||||||
|
except BaseException:
|
||||||
|
self._terminal = True
|
||||||
|
raise
|
||||||
|
|
||||||
|
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 FinalSubmitPanelError("拼多多版本与 T-106/T-107 证据不一致,已停止操作。")
|
||||||
|
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 FinalSubmitPanelError("拼多多不是唯一前台应用,已停止操作。")
|
||||||
|
if self._device.display_size() != EXPECTED_SCREEN_SIZE:
|
||||||
|
raise FinalSubmitPanelError("屏幕坐标空间与 T-106/T-107 证据不一致,已停止操作。")
|
||||||
|
|
||||||
|
def _read_hierarchy(self) -> str:
|
||||||
|
value = self._device.dump_window_hierarchy()
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
raise FinalSubmitPanelError("节点树读取失败,已停止操作。")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _require_active(self) -> None:
|
||||||
|
if self._terminal:
|
||||||
|
raise FinalSubmitPanelError("最终面板流程已进入不可重入终止态。")
|
||||||
|
|
||||||
|
|
||||||
|
class UiautomatorFinalSubmitPanelAdapter(FinalSubmitPanelDevice):
|
||||||
|
"""只暴露截图/XML 读取和一个 Android 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._back_attempted = False
|
||||||
|
self._back_outcome = "not_attempted"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def page_control_action_attempts(self) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@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 FinalSubmitPanelAdapterError("无法读取应用版本,已停止操作。")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def current_foreground(self) -> dict[str, str]:
|
||||||
|
try:
|
||||||
|
value = self._foreground_reader.read(self._serial)
|
||||||
|
except FinalSubmitPanelError:
|
||||||
|
raise
|
||||||
|
except Exception as error:
|
||||||
|
raise FinalSubmitPanelAdapterError("无法读取 Android 前台摘要,已停止操作。") from error
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise FinalSubmitPanelAdapterError("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 FinalSubmitPanelAdapterError("无法读取屏幕坐标空间,已停止操作。")
|
||||||
|
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 FinalSubmitPanelAdapterError("节点树读取失败,已停止操作。")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def capture_screenshot(self) -> str:
|
||||||
|
value = self._call("jsonrpc_call", "takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout)
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise FinalSubmitPanelAdapterError("Gate3 原始截图读取失败,已停止操作。")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def leave_final_submit_panel_once(self) -> None:
|
||||||
|
if self._back_attempted:
|
||||||
|
raise FinalSubmitPanelAdapterError("安全返回已尝试过,拒绝重试。")
|
||||||
|
# RPC 超时无法证明 Back 未送达,必须先封存唯一动作机会。
|
||||||
|
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 FinalSubmitPanelTimeoutError("T-107 设备调用超时,已停止操作。") from error
|
||||||
|
except FinalSubmitPanelError:
|
||||||
|
raise
|
||||||
|
except Exception as error:
|
||||||
|
raise FinalSubmitPanelAdapterError("T-107 设备调用失败,已停止操作。") from error
|
||||||
|
|
||||||
|
|
||||||
|
class FinalSubmitPanelRunner:
|
||||||
|
"""从人工停驻的合并式最终面板完成围栏前只读 dry-run。"""
|
||||||
|
|
||||||
|
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,
|
||||||
|
gate2: Gate2Observation,
|
||||||
|
output_directory: Path,
|
||||||
|
) -> FinalSubmitPanelRunResult:
|
||||||
|
target = Path(output_directory)
|
||||||
|
staging: Path | None = None
|
||||||
|
try:
|
||||||
|
_validate_preflight(serial, goods_id, gate2, target)
|
||||||
|
staging = _prepare_staging(target)
|
||||||
|
inspection = self._adb_client.inspect(serial)
|
||||||
|
_require_expected_device(inspection)
|
||||||
|
adapter = UiautomatorFinalSubmitPanelAdapter(
|
||||||
|
self._connector(serial),
|
||||||
|
self._foreground_reader,
|
||||||
|
serial,
|
||||||
|
self._timeout,
|
||||||
|
)
|
||||||
|
flow = FinalSubmitPanelFlow(
|
||||||
|
adapter,
|
||||||
|
wait_timeout_seconds=self._timeout,
|
||||||
|
monotonic_clock=self._clock,
|
||||||
|
)
|
||||||
|
flow.require_ready_for_read()
|
||||||
|
screenshot_path = staging / "gate3_screenshot.png"
|
||||||
|
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
|
||||||
|
_require_screenshot(screenshot_path)
|
||||||
|
captured_at = datetime.now(UTC)
|
||||||
|
observation = flow.observe(gate2, screenshot_path, captured_at)
|
||||||
|
flow.exit_final_submit_panel_safely()
|
||||||
|
_require_action_audit(adapter, flow)
|
||||||
|
|
||||||
|
manifest_path = staging / "manifest.json"
|
||||||
|
manifest_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
_manifest(inspection, serial, gate2, observation, screenshot_path, adapter),
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.rename(staging, target)
|
||||||
|
staging = None
|
||||||
|
except (DeviceConnectionError, FinalSubmitPanelError):
|
||||||
|
_clean_staging(staging)
|
||||||
|
raise
|
||||||
|
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||||
|
_clean_staging(staging)
|
||||||
|
raise FinalSubmitPanelTimeoutError("T-107 真机运行超时,未发布证据。") from error
|
||||||
|
except OSError as error:
|
||||||
|
_clean_staging(staging)
|
||||||
|
raise FinalSubmitPanelError("T-107 证据无法原子发布。") from error
|
||||||
|
except Exception as error:
|
||||||
|
_clean_staging(staging)
|
||||||
|
raise FinalSubmitPanelError("T-107 真机运行未完成。") from error
|
||||||
|
|
||||||
|
published = replace(observation, screenshot_path=target / "gate3_screenshot.png")
|
||||||
|
return FinalSubmitPanelRunResult(
|
||||||
|
output_directory=target,
|
||||||
|
screenshot_path=target / "gate3_screenshot.png",
|
||||||
|
manifest_path=target / "manifest.json",
|
||||||
|
observation=published,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
gate2: object,
|
||||||
|
target: Path,
|
||||||
|
) -> None:
|
||||||
|
if type(serial) is not str or not serial.strip() or serial != serial.strip():
|
||||||
|
raise FinalSubmitPanelError("必须显式提供非空设备通道。")
|
||||||
|
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
|
||||||
|
raise FinalSubmitPanelError("商品不是 T-107 已批准目标。")
|
||||||
|
if not isinstance(gate2, Gate2Observation) or not gate2.screenshot_path.is_file():
|
||||||
|
raise FinalSubmitPanelError("Gate2 原始截图不存在,已停止操作。")
|
||||||
|
if target.exists() or not target.name:
|
||||||
|
raise FinalSubmitPanelError("输出目录必须是不存在的明确新目录。")
|
||||||
|
|
||||||
|
|
||||||
|
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 FinalSubmitPanelError("输出目录不可写,已停止操作。") 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 FinalSubmitPanelError("设备型号或 Android 版本与 T-106/T-107 证据不一致。")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_screenshot(path: Path) -> None:
|
||||||
|
try:
|
||||||
|
with Image.open(path) as image:
|
||||||
|
image.load()
|
||||||
|
if image.size != EXPECTED_SCREEN_SIZE or image.format != "PNG":
|
||||||
|
raise FinalSubmitPanelError("Gate3 截图尺寸或格式与证据不一致。")
|
||||||
|
except FinalSubmitPanelError:
|
||||||
|
raise
|
||||||
|
except (OSError, UnidentifiedImageError) as error:
|
||||||
|
raise FinalSubmitPanelError("Gate3 截图不是有效图像。") from error
|
||||||
|
|
||||||
|
|
||||||
|
def _require_action_audit(adapter: UiautomatorFinalSubmitPanelAdapter, flow: FinalSubmitPanelFlow) -> None:
|
||||||
|
if (
|
||||||
|
adapter.page_control_action_attempts != 0
|
||||||
|
or adapter.back_attempts != 1
|
||||||
|
or adapter.back_outcome != "completed"
|
||||||
|
or not flow.exited
|
||||||
|
):
|
||||||
|
raise FinalSubmitPanelError("T-107 动作审计链不完整,拒绝发布。")
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest(
|
||||||
|
inspection: DeviceInspection,
|
||||||
|
serial: str,
|
||||||
|
gate2: Gate2Observation,
|
||||||
|
observation: Gate3Observation,
|
||||||
|
screenshot_path: Path,
|
||||||
|
adapter: UiautomatorFinalSubmitPanelAdapter,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"operation": "t107-final-submit-panel-dry-run",
|
||||||
|
"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": {
|
||||||
|
"gate2_panel_total_price": observation.gate2_panel_total_price,
|
||||||
|
"gate3_submit_amount": observation.gate3_submit_amount,
|
||||||
|
"max_total_price": observation.max_total_price,
|
||||||
|
},
|
||||||
|
"submit_control": {
|
||||||
|
"text": observation.submit_control_text,
|
||||||
|
"match_count": observation.submit_control_match_count,
|
||||||
|
"enabled": observation.submit_control_enabled,
|
||||||
|
"nearest_clickable_ancestor_unique": observation.nearest_clickable_ancestor_unique,
|
||||||
|
},
|
||||||
|
"gate2_evidence": {
|
||||||
|
"captured_at": gate2.captured_at.isoformat(),
|
||||||
|
"screenshot_sha256": _sha256_file(gate2.screenshot_path),
|
||||||
|
},
|
||||||
|
"gate3_evidence": {
|
||||||
|
"path": screenshot_path.name,
|
||||||
|
"sha256": _sha256_file(screenshot_path),
|
||||||
|
},
|
||||||
|
"action_audit": {
|
||||||
|
"page_control_action_attempts": adapter.page_control_action_attempts,
|
||||||
|
"back_attempts": adapter.back_attempts,
|
||||||
|
"back_rpc_outcome": adapter.back_outcome,
|
||||||
|
},
|
||||||
|
"safe_exit": "completed",
|
||||||
|
"review_status": "human_review_required",
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<hierarchy rotation="0">
|
||||||
|
<!-- T-106 合并式最终面板证据:只保留规格摘要、顶部 Gate2、数量和 Gate3 最终文本。 -->
|
||||||
|
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[0,551][1080,938]" 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,575][1053,647]" 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,575][740,647]" 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,580][722,647]" 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,659][1053,721]" 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,827][645,902]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||||
|
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,827][645,902]" 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,827][474,902]" 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,827][561,902]" 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,827][645,902]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
<!-- 最近可点击祖先是 FrameLayout;更宽的可点击祖先允许存在,但不是返回对象。 -->
|
||||||
|
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||||
|
<node package="com.xunmeng.pinduoduo" class="android.widget.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 package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[354,2181][726,2328]" clickable="false" 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>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</hierarchy>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<hierarchy rotation="0">
|
||||||
|
<!-- T-106 人工一次 Back 后的同商品标题正锚。 -->
|
||||||
|
<node resource-id="com.xunmeng.pinduoduo:id/tv_title" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1571][1044,1689]" content-desc="2026年新款高档重工潮流烫钻中长款T恤显瘦宽松上衣淡人穿搭" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" long-clickable="true">
|
||||||
|
<node text="2026年新款高档重工潮流烫钻中长款T恤显瘦宽松" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1571][1023,1624]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||||
|
<node text="上衣淡人穿搭" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1636][306,1689]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||||
|
</node>
|
||||||
|
<!-- 底部入口只作同商品页面身份;其中数字不解析、不返回、不充当任何价格闸门。 -->
|
||||||
|
<node content-desc="快要抢光¥12.88" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[446,2166][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" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[688,2184][1042,2253]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||||
|
<node text="免拼购买" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[688,2256][856,2305]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||||
|
</node>
|
||||||
|
</hierarchy>
|
||||||
@@ -0,0 +1,684 @@
|
|||||||
|
"""T-107 合并式最终提交面板 Gate3 与一次安全返回测试。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import base64
|
||||||
|
from contextlib import redirect_stderr
|
||||||
|
from dataclasses import FrozenInstanceError, replace
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from functools import lru_cache
|
||||||
|
from importlib.util import module_from_spec, spec_from_file_location
|
||||||
|
from io import BytesIO, StringIO
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||||
|
|
||||||
|
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
|
||||||
|
from cmbuyer_client.pdd.final_submit_panel import (
|
||||||
|
FinalSubmitPanelError,
|
||||||
|
FinalSubmitPanelOverCapError,
|
||||||
|
Gate3Observation,
|
||||||
|
observe_gate3,
|
||||||
|
returned_product_projection,
|
||||||
|
)
|
||||||
|
from cmbuyer_client.pdd.final_submit_panel_runner import (
|
||||||
|
FinalSubmitPanelDevice,
|
||||||
|
FinalSubmitPanelFlow,
|
||||||
|
FinalSubmitPanelRunner,
|
||||||
|
FinalSubmitPanelTimeoutError,
|
||||||
|
UiautomatorFinalSubmitPanelAdapter,
|
||||||
|
)
|
||||||
|
from cmbuyer_client.pdd.quantity_gate2 import (
|
||||||
|
EXPECTED_GOODS_ID,
|
||||||
|
TASK_COLOR,
|
||||||
|
TASK_SIZE,
|
||||||
|
Gate2Observation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SERIAL = "192.168.0.173:5555"
|
||||||
|
FIXTURES = Path(__file__).with_name("fixtures")
|
||||||
|
PANEL_FIXTURE = FIXTURES / "final_submit_panel_8_17_0.xml"
|
||||||
|
EXIT_FIXTURE = FIXTURES / "final_submit_panel_exit_8_17_0.xml"
|
||||||
|
PANEL = PANEL_FIXTURE.read_text(encoding="utf-8")
|
||||||
|
EXIT = EXIT_FIXTURE.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _gate2(screenshot_path: Path = Path("gate2.png"), **changes: object) -> Gate2Observation:
|
||||||
|
value = Gate2Observation(
|
||||||
|
requested_color=TASK_COLOR,
|
||||||
|
requested_size=TASK_SIZE,
|
||||||
|
actual_color=TASK_COLOR,
|
||||||
|
actual_size=TASK_SIZE,
|
||||||
|
requested_quantity=2,
|
||||||
|
quantity_read=2,
|
||||||
|
gate1_unit_price="12.88",
|
||||||
|
gate2_panel_total_price="32.76",
|
||||||
|
max_total_price="40.00",
|
||||||
|
screenshot_path=screenshot_path,
|
||||||
|
captured_at=datetime(2026, 8, 6, 3, 7, 33, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
return replace(value, **changes)
|
||||||
|
|
||||||
|
|
||||||
|
def _observe(raw: str = PANEL, gate2: Gate2Observation | None = None) -> Gate3Observation:
|
||||||
|
return observe_gate3(
|
||||||
|
raw,
|
||||||
|
_gate2() if gate2 is None else gate2,
|
||||||
|
Path("gate3.png"),
|
||||||
|
datetime(2026, 8, 6, 3, 40, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _edit_one(raw: str, predicate: object, **attributes: str) -> str:
|
||||||
|
root = ElementTree.fromstring(raw)
|
||||||
|
matches = [node for node in root.iter("node") if predicate(node)] # type: ignore[operator]
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise AssertionError(f"expected one node, got {len(matches)}")
|
||||||
|
for key, value in attributes.items():
|
||||||
|
matches[0].set(key.replace("_", "-"), value)
|
||||||
|
return ElementTree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
|
def _duplicate_submit(raw: str) -> str:
|
||||||
|
root = ElementTree.fromstring(raw)
|
||||||
|
submit = next(node for node in root.iter("node") if node.get("text") == "提交订单 ¥32.76")
|
||||||
|
parent = next(node for node in root.iter() if submit in list(node))
|
||||||
|
parent.append(ElementTree.fromstring(ElementTree.tostring(submit, encoding="unicode")))
|
||||||
|
return ElementTree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _png_base64() -> str:
|
||||||
|
raw = BytesIO()
|
||||||
|
Image.new("RGB", (1080, 2376), color="white").save(raw, format="PNG")
|
||||||
|
return base64.b64encode(raw.getvalue()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
class FinalSubmitPanelObserverTests(unittest.TestCase):
|
||||||
|
def test_observer_reads_two_independent_amount_roles_and_safe_control_summary(self) -> None:
|
||||||
|
observation = _observe()
|
||||||
|
self.assertEqual(observation.gate2_panel_total_price, "32.76")
|
||||||
|
self.assertEqual(observation.gate3_submit_amount, "32.76")
|
||||||
|
self.assertEqual(observation.submit_control_text, "提交订单 ¥32.76")
|
||||||
|
self.assertEqual(observation.submit_control_match_count, 1)
|
||||||
|
self.assertTrue(observation.submit_control_enabled)
|
||||||
|
self.assertTrue(observation.nearest_clickable_ancestor_unique)
|
||||||
|
self.assertEqual(observation.quantity_read, 2)
|
||||||
|
|
||||||
|
def test_dynamic_canonical_amount_is_allowed_only_when_both_roles_and_gate2_agree(self) -> None:
|
||||||
|
raw = PANEL.replace("快卖完 ¥32.76", "快卖完 ¥39.99").replace(
|
||||||
|
"提交订单 ¥32.76", "提交订单 ¥39.99"
|
||||||
|
)
|
||||||
|
observation = _observe(raw, _gate2(gate2_panel_total_price="39.99"))
|
||||||
|
self.assertEqual(observation.gate2_panel_total_price, "39.99")
|
||||||
|
self.assertEqual(observation.gate3_submit_amount, "39.99")
|
||||||
|
|
||||||
|
def test_gate2_top_role_must_match_trusted_gate2_observation(self) -> None:
|
||||||
|
raw = PANEL.replace("快卖完 ¥32.76", "快卖完 ¥32.77")
|
||||||
|
with self.assertRaises(FinalSubmitPanelError):
|
||||||
|
_observe(raw)
|
||||||
|
|
||||||
|
def test_gate3_must_strictly_equal_current_gate2_top_role(self) -> None:
|
||||||
|
raw = PANEL.replace("提交订单 ¥32.76", "提交订单 ¥32.77")
|
||||||
|
with self.assertRaises(FinalSubmitPanelError):
|
||||||
|
_observe(raw)
|
||||||
|
|
||||||
|
def test_over_cap_gate2_fails_before_page_result(self) -> None:
|
||||||
|
raw = PANEL.replace("快卖完 ¥32.76", "快卖完 ¥40.01").replace(
|
||||||
|
"提交订单 ¥32.76", "提交订单 ¥40.01"
|
||||||
|
)
|
||||||
|
with self.assertRaises(FinalSubmitPanelOverCapError):
|
||||||
|
_observe(raw, _gate2(gate2_panel_total_price="40.01"))
|
||||||
|
|
||||||
|
def test_noncanonical_money_never_becomes_a_candidate(self) -> None:
|
||||||
|
for text in ("提交订单 ¥032.76", "提交订单 ¥32.7", "提交订单 ¥ 32.76", "提交订单 32.76"):
|
||||||
|
with self.subTest(text=text), self.assertRaises(FinalSubmitPanelError):
|
||||||
|
_observe(PANEL.replace("提交订单 ¥32.76", text))
|
||||||
|
with self.assertRaises(FinalSubmitPanelError):
|
||||||
|
_observe(gate2=_gate2(gate2_panel_total_price="032.76"))
|
||||||
|
|
||||||
|
def test_submit_text_requires_exactly_one_complete_match(self) -> None:
|
||||||
|
with self.assertRaises(FinalSubmitPanelError):
|
||||||
|
_observe(PANEL.replace("提交订单 ¥32.76", "创建订单 ¥32.76"))
|
||||||
|
with self.assertRaises(FinalSubmitPanelError):
|
||||||
|
_observe(_duplicate_submit(PANEL))
|
||||||
|
|
||||||
|
def test_submit_leaf_must_be_inert_enabled_and_evidence_bound(self) -> None:
|
||||||
|
leaf = lambda node: node.get("text") == "提交订单 ¥32.76"
|
||||||
|
for change in (
|
||||||
|
{"clickable": "true"},
|
||||||
|
{"enabled": "false"},
|
||||||
|
{"bounds": "[365,2225][714,2284]"},
|
||||||
|
{"class": "android.widget.Button"},
|
||||||
|
):
|
||||||
|
with self.subTest(change=change), self.assertRaises(FinalSubmitPanelError):
|
||||||
|
_observe(_edit_one(PANEL, leaf, **change))
|
||||||
|
|
||||||
|
def test_nearest_clickable_ancestor_is_exact_but_broader_clickable_ancestor_is_allowed(self) -> None:
|
||||||
|
self.assertTrue(_observe().nearest_clickable_ancestor_unique)
|
||||||
|
frame = lambda node: node.get("class") == "android.widget.FrameLayout" and node.get("bounds") == "[0,2181][1080,2328]"
|
||||||
|
for change in ({"clickable": "false"}, {"enabled": "false"}, {"bounds": "[1,2181][1080,2328]"}):
|
||||||
|
with self.subTest(change=change), self.assertRaises(FinalSubmitPanelError):
|
||||||
|
_observe(_edit_one(PANEL, frame, **change))
|
||||||
|
|
||||||
|
def test_specs_quantity_and_panel_structure_are_exact(self) -> None:
|
||||||
|
cases = (
|
||||||
|
PANEL.replace("黑色 CHA (纯棉)", "白色 CHA (纯棉)"),
|
||||||
|
PANEL.replace("M(建议100-115)", "S(建议80-100)"),
|
||||||
|
_edit_one(PANEL, lambda node: node.get("class") == "android.widget.EditText", text="1"),
|
||||||
|
_edit_one(PANEL, lambda node: node.get("content-desc") == "增加数量", bounds="[568,827][645,902]"),
|
||||||
|
)
|
||||||
|
for raw in cases:
|
||||||
|
with self.subTest(), self.assertRaises(FinalSubmitPanelError):
|
||||||
|
_observe(raw)
|
||||||
|
|
||||||
|
def test_trusted_gate2_specs_quantity_and_evidence_metadata_are_required(self) -> None:
|
||||||
|
changes = (
|
||||||
|
{"actual_color": "白色"},
|
||||||
|
{"actual_size": "S"},
|
||||||
|
{"requested_quantity": 1},
|
||||||
|
{"quantity_read": 1},
|
||||||
|
{"screenshot_path": Path()},
|
||||||
|
{"captured_at": datetime(2026, 8, 6, 3, 7, 33)},
|
||||||
|
)
|
||||||
|
for change in changes:
|
||||||
|
with self.subTest(change=change), self.assertRaises(FinalSubmitPanelError):
|
||||||
|
_observe(gate2=_gate2(**change))
|
||||||
|
with self.assertRaises(FinalSubmitPanelError):
|
||||||
|
observe_gate3(PANEL, object(), Path("gate3.png"), datetime.now(UTC)) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
def test_invalid_xml_and_root_fail_closed(self) -> None:
|
||||||
|
for raw in ("", "<hierarchy>", "<root />"):
|
||||||
|
with self.subTest(raw=raw), self.assertRaises(FinalSubmitPanelError):
|
||||||
|
_observe(raw)
|
||||||
|
|
||||||
|
def test_gate3_metadata_must_be_timezone_aware_and_named(self) -> None:
|
||||||
|
with self.assertRaises(FinalSubmitPanelError):
|
||||||
|
observe_gate3(PANEL, _gate2(), Path(), datetime.now(UTC))
|
||||||
|
with self.assertRaises(FinalSubmitPanelError):
|
||||||
|
observe_gate3(PANEL, _gate2(), Path("gate3.png"), datetime(2026, 8, 6))
|
||||||
|
|
||||||
|
def test_observation_is_frozen_and_contains_no_action_material(self) -> None:
|
||||||
|
observation = _observe()
|
||||||
|
expected = {
|
||||||
|
"requested_color", "requested_size", "actual_color", "actual_size",
|
||||||
|
"requested_quantity", "quantity_read", "gate2_panel_total_price",
|
||||||
|
"gate3_submit_amount", "max_total_price", "submit_control_text",
|
||||||
|
"submit_control_match_count", "submit_control_enabled",
|
||||||
|
"nearest_clickable_ancestor_unique", "screenshot_path", "captured_at",
|
||||||
|
}
|
||||||
|
self.assertEqual(set(observation.__dict__), expected)
|
||||||
|
for forbidden in ("selector", "bounds", "node", "ancestor_handle", "clickable_object"):
|
||||||
|
self.assertNotIn(forbidden, observation.__dict__)
|
||||||
|
with self.assertRaises(FrozenInstanceError):
|
||||||
|
observation.gate3_submit_amount = "1.00" # type: ignore[misc]
|
||||||
|
|
||||||
|
|
||||||
|
class ReturnedProductProjectionTests(unittest.TestCase):
|
||||||
|
def test_t106_return_fixture_matches_stable_same_product_projection(self) -> None:
|
||||||
|
first = returned_product_projection(EXIT)
|
||||||
|
second = returned_product_projection(EXIT)
|
||||||
|
self.assertEqual(first, second)
|
||||||
|
self.assertEqual(first[0], "final_submit_panel_exit_8_17_0")
|
||||||
|
|
||||||
|
def test_title_or_entry_drift_fails_closed(self) -> None:
|
||||||
|
cases = (
|
||||||
|
EXIT.replace("上衣淡人穿搭", "其他商品"),
|
||||||
|
EXIT.replace("免拼购买", "单独购买"),
|
||||||
|
EXIT.replace("快要抢光¥12.88", "快要抢光¥12.89"),
|
||||||
|
)
|
||||||
|
for raw in cases:
|
||||||
|
with self.subTest(), self.assertRaises(FinalSubmitPanelError):
|
||||||
|
returned_product_projection(raw)
|
||||||
|
|
||||||
|
def test_dangerous_live_action_rejects_return_page(self) -> None:
|
||||||
|
dangerous = (
|
||||||
|
'<node text="立即支付" package="com.xunmeng.pinduoduo" class="android.widget.TextView" '
|
||||||
|
'resource-id="" bounds="[0,0][1,1]" clickable="true" enabled="true" '
|
||||||
|
'visible-to-user="true" selected="false" scrollable="false" />'
|
||||||
|
)
|
||||||
|
raw = EXIT.replace("</hierarchy>", dangerous + "</hierarchy>")
|
||||||
|
with self.assertRaises(FinalSubmitPanelError):
|
||||||
|
returned_product_projection(raw)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.value = 0.0
|
||||||
|
|
||||||
|
def __call__(self) -> float:
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
def sleep(self, duration: float) -> None:
|
||||||
|
self.value += duration
|
||||||
|
|
||||||
|
|
||||||
|
class FakePanelDevice:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
panel: str = PANEL,
|
||||||
|
exit_page: str = EXIT,
|
||||||
|
version: str = "8.17.0",
|
||||||
|
package: str = "com.xunmeng.pinduoduo",
|
||||||
|
size: tuple[int, int] = (1080, 2376),
|
||||||
|
back_error: Exception | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.panel = panel
|
||||||
|
self.exit_page = exit_page
|
||||||
|
self.version = version
|
||||||
|
self.package = package
|
||||||
|
self.size = size
|
||||||
|
self.back_error = back_error
|
||||||
|
self.after_back = False
|
||||||
|
self.back_attempts = 0
|
||||||
|
self.calls: list[str] = []
|
||||||
|
|
||||||
|
def app_info(self, package_name: str) -> dict[str, str]:
|
||||||
|
self.calls.append("app_info")
|
||||||
|
return {"versionName": self.version}
|
||||||
|
|
||||||
|
def current_foreground(self) -> dict[str, str]:
|
||||||
|
self.calls.append("foreground")
|
||||||
|
return {"package": self.package, "activity": ".activity.NewPageActivity"}
|
||||||
|
|
||||||
|
def display_size(self) -> tuple[int, int]:
|
||||||
|
self.calls.append("size")
|
||||||
|
return self.size
|
||||||
|
|
||||||
|
def dump_window_hierarchy(self) -> str:
|
||||||
|
self.calls.append("dump")
|
||||||
|
return self.exit_page if self.after_back else self.panel
|
||||||
|
|
||||||
|
def capture_screenshot(self) -> str:
|
||||||
|
self.calls.append("screenshot")
|
||||||
|
return _png_base64()
|
||||||
|
|
||||||
|
def leave_final_submit_panel_once(self) -> None:
|
||||||
|
self.calls.append("back")
|
||||||
|
self.back_attempts += 1
|
||||||
|
self.after_back = True
|
||||||
|
if self.back_error is not None:
|
||||||
|
raise self.back_error
|
||||||
|
|
||||||
|
|
||||||
|
def _flow(device: FakePanelDevice, timeout: float = 0.02) -> FinalSubmitPanelFlow:
|
||||||
|
clock = FakeClock()
|
||||||
|
return FinalSubmitPanelFlow(
|
||||||
|
device,
|
||||||
|
wait_timeout_seconds=timeout,
|
||||||
|
poll_interval_seconds=0.005,
|
||||||
|
monotonic_clock=clock,
|
||||||
|
sleep_function=clock.sleep,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FinalSubmitPanelFlowTests(unittest.TestCase):
|
||||||
|
def test_success_has_zero_page_control_actions_and_one_back(self) -> None:
|
||||||
|
device = FakePanelDevice()
|
||||||
|
flow = _flow(device)
|
||||||
|
observation = flow.observe(_gate2(), Path("gate3.png"), datetime.now(UTC))
|
||||||
|
flow.exit_final_submit_panel_safely()
|
||||||
|
self.assertEqual(observation.gate3_submit_amount, "32.76")
|
||||||
|
self.assertEqual(device.back_attempts, 1)
|
||||||
|
self.assertEqual(device.calls.count("back"), 1)
|
||||||
|
self.assertTrue(flow.exited)
|
||||||
|
self.assertFalse(hasattr(device, "click"))
|
||||||
|
|
||||||
|
def test_observation_failure_never_attempts_back(self) -> None:
|
||||||
|
device = FakePanelDevice(panel=PANEL.replace("提交订单 ¥32.76", "提交订单 ¥32.77"))
|
||||||
|
flow = _flow(device)
|
||||||
|
with self.assertRaises(FinalSubmitPanelError):
|
||||||
|
flow.observe(_gate2(), Path("gate3.png"), datetime.now(UTC))
|
||||||
|
self.assertEqual(device.back_attempts, 0)
|
||||||
|
with self.assertRaises(FinalSubmitPanelError):
|
||||||
|
flow.exit_final_submit_panel_safely()
|
||||||
|
|
||||||
|
def test_environment_drift_fails_before_back(self) -> None:
|
||||||
|
devices = (
|
||||||
|
FakePanelDevice(version="8.18.0"),
|
||||||
|
FakePanelDevice(package="com.android.systemui"),
|
||||||
|
FakePanelDevice(size=(1080, 2400)),
|
||||||
|
)
|
||||||
|
for device in devices:
|
||||||
|
with self.subTest(), self.assertRaises(FinalSubmitPanelError):
|
||||||
|
_flow(device).observe(_gate2(), Path("gate3.png"), datetime.now(UTC))
|
||||||
|
self.assertEqual(device.back_attempts, 0)
|
||||||
|
|
||||||
|
def test_back_result_unknown_is_not_retried(self) -> None:
|
||||||
|
device = FakePanelDevice(back_error=TimeoutError("unknown"))
|
||||||
|
flow = _flow(device)
|
||||||
|
flow.observe(_gate2(), Path("gate3.png"), datetime.now(UTC))
|
||||||
|
with self.assertRaises(TimeoutError):
|
||||||
|
flow.exit_final_submit_panel_safely()
|
||||||
|
self.assertEqual(device.back_attempts, 1)
|
||||||
|
with self.assertRaises(FinalSubmitPanelError):
|
||||||
|
flow.exit_final_submit_panel_safely()
|
||||||
|
self.assertEqual(device.back_attempts, 1)
|
||||||
|
|
||||||
|
def test_unconfirmed_return_times_out_after_exactly_one_back(self) -> None:
|
||||||
|
device = FakePanelDevice(exit_page=PANEL)
|
||||||
|
flow = _flow(device)
|
||||||
|
flow.observe(_gate2(), Path("gate3.png"), datetime.now(UTC))
|
||||||
|
with self.assertRaises(FinalSubmitPanelTimeoutError):
|
||||||
|
flow.exit_final_submit_panel_safely()
|
||||||
|
self.assertEqual(device.back_attempts, 1)
|
||||||
|
|
||||||
|
def test_single_positive_return_frame_then_drift_never_succeeds(self) -> None:
|
||||||
|
class AlternatingDevice(FakePanelDevice):
|
||||||
|
def dump_window_hierarchy(self) -> str:
|
||||||
|
self.calls.append("dump")
|
||||||
|
if not self.after_back:
|
||||||
|
return self.panel
|
||||||
|
return EXIT if self.calls.count("dump") % 2 == 1 else PANEL
|
||||||
|
|
||||||
|
device = AlternatingDevice()
|
||||||
|
flow = _flow(device)
|
||||||
|
flow.observe(_gate2(), Path("gate3.png"), datetime.now(UTC))
|
||||||
|
with self.assertRaises(FinalSubmitPanelTimeoutError):
|
||||||
|
flow.exit_final_submit_panel_safely()
|
||||||
|
self.assertEqual(device.back_attempts, 1)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeForegroundReader:
|
||||||
|
def __init__(self, package: str = "com.xunmeng.pinduoduo") -> None:
|
||||||
|
self.package = package
|
||||||
|
self.calls: list[str] = []
|
||||||
|
|
||||||
|
def read(self, serial: str) -> dict[str, str]:
|
||||||
|
self.calls.append(serial)
|
||||||
|
return {"package": self.package, "activity": ".activity.NewPageActivity"}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRawDevice:
|
||||||
|
def __init__(self, *, version: str = "8.17.0", back_timeout: bool = False) -> None:
|
||||||
|
self.version = version
|
||||||
|
self.current = PANEL
|
||||||
|
self.calls: list[tuple[object, ...]] = []
|
||||||
|
self.back_timeout = back_timeout
|
||||||
|
|
||||||
|
def app_info(self, package: str) -> dict[str, str]:
|
||||||
|
self.calls.append(("app_info", package))
|
||||||
|
return {"versionName": self.version}
|
||||||
|
|
||||||
|
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 == "takeScreenshot":
|
||||||
|
return _png_base64()
|
||||||
|
if method == "pressKey":
|
||||||
|
self.current = EXIT
|
||||||
|
if self.back_timeout:
|
||||||
|
raise TimeoutError("may have been delivered")
|
||||||
|
return None
|
||||||
|
raise AssertionError(method)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAdbClient:
|
||||||
|
def __init__(self, *, model: str = "PKG110", android_version: str = "16") -> None:
|
||||||
|
self.inspection = DeviceInspection(
|
||||||
|
device=AdbDevice(serial=SERIAL, state="device", model=model),
|
||||||
|
model=model,
|
||||||
|
android_version=android_version,
|
||||||
|
)
|
||||||
|
self.calls: list[str] = []
|
||||||
|
|
||||||
|
def inspect(self, serial: str) -> DeviceInspection:
|
||||||
|
self.calls.append(serial)
|
||||||
|
return self.inspection
|
||||||
|
|
||||||
|
|
||||||
|
class FinalSubmitPanelRunnerTests(unittest.TestCase):
|
||||||
|
def test_runner_publishes_gate3_evidence_after_zero_control_actions_and_one_back(self) -> None:
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
base = Path(temporary)
|
||||||
|
gate2_path = base / "gate2.png"
|
||||||
|
gate2_path.write_bytes(base64.b64decode(_png_base64()))
|
||||||
|
target = base / "gate3"
|
||||||
|
raw = FakeRawDevice()
|
||||||
|
result = FinalSubmitPanelRunner(
|
||||||
|
FakeAdbClient(),
|
||||||
|
lambda serial: raw,
|
||||||
|
FakeForegroundReader(),
|
||||||
|
timeout_seconds=0.2,
|
||||||
|
).run(SERIAL, EXPECTED_GOODS_ID, _gate2(gate2_path), 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.assertNotIn("click", methods)
|
||||||
|
self.assertEqual(methods.count("pressKey"), 1)
|
||||||
|
self.assertEqual(methods.count("takeScreenshot"), 1)
|
||||||
|
self.assertEqual(manifest["prices"]["gate2_panel_total_price"], "32.76")
|
||||||
|
self.assertEqual(manifest["prices"]["gate3_submit_amount"], "32.76")
|
||||||
|
self.assertEqual(manifest["submit_control"]["match_count"], 1)
|
||||||
|
self.assertTrue(manifest["submit_control"]["nearest_clickable_ancestor_unique"])
|
||||||
|
self.assertEqual(manifest["action_audit"]["page_control_action_attempts"], 0)
|
||||||
|
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(gate2_path), serialized)
|
||||||
|
|
||||||
|
def test_gate3_mismatch_publishes_nothing_and_never_attempts_back(self) -> None:
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
base = Path(temporary)
|
||||||
|
gate2_path = base / "gate2.png"
|
||||||
|
gate2_path.write_bytes(base64.b64decode(_png_base64()))
|
||||||
|
raw = FakeRawDevice()
|
||||||
|
raw.current = PANEL.replace("提交订单 ¥32.76", "提交订单 ¥32.77")
|
||||||
|
target = base / "gate3"
|
||||||
|
with self.assertRaises(FinalSubmitPanelError):
|
||||||
|
FinalSubmitPanelRunner(
|
||||||
|
FakeAdbClient(), lambda serial: raw, FakeForegroundReader(), timeout_seconds=0.1
|
||||||
|
).run(SERIAL, EXPECTED_GOODS_ID, _gate2(gate2_path), target)
|
||||||
|
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
|
||||||
|
self.assertEqual(methods.count("pressKey"), 0)
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
self.assertEqual(list(base.glob(".gate3.staging-*")), [])
|
||||||
|
|
||||||
|
def test_ambiguous_back_is_attempted_once_and_never_published(self) -> None:
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
base = Path(temporary)
|
||||||
|
gate2_path = base / "gate2.png"
|
||||||
|
gate2_path.write_bytes(base64.b64decode(_png_base64()))
|
||||||
|
raw = FakeRawDevice(back_timeout=True)
|
||||||
|
target = base / "gate3"
|
||||||
|
with self.assertRaises(FinalSubmitPanelError):
|
||||||
|
FinalSubmitPanelRunner(
|
||||||
|
FakeAdbClient(), lambda serial: raw, FakeForegroundReader(), timeout_seconds=0.1
|
||||||
|
).run(SERIAL, EXPECTED_GOODS_ID, _gate2(gate2_path), target)
|
||||||
|
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
|
||||||
|
self.assertEqual(methods.count("pressKey"), 1)
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
|
||||||
|
def test_version_drift_stops_before_screenshot_or_back(self) -> None:
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
base = Path(temporary)
|
||||||
|
gate2_path = base / "gate2.png"
|
||||||
|
gate2_path.write_bytes(base64.b64decode(_png_base64()))
|
||||||
|
raw = FakeRawDevice(version="8.18.0")
|
||||||
|
with self.assertRaises(FinalSubmitPanelError):
|
||||||
|
FinalSubmitPanelRunner(
|
||||||
|
FakeAdbClient(), lambda serial: raw, FakeForegroundReader(), timeout_seconds=0.1
|
||||||
|
).run(SERIAL, EXPECTED_GOODS_ID, _gate2(gate2_path), base / "gate3")
|
||||||
|
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
|
||||||
|
self.assertNotIn("takeScreenshot", methods)
|
||||||
|
self.assertNotIn("pressKey", methods)
|
||||||
|
|
||||||
|
|
||||||
|
class FinalSubmitPanelAdapterTests(unittest.TestCase):
|
||||||
|
def test_adapter_rpc_surface_is_read_only_plus_one_back(self) -> None:
|
||||||
|
raw = FakeRawDevice()
|
||||||
|
adapter = UiautomatorFinalSubmitPanelAdapter(raw, FakeForegroundReader(), SERIAL, 0.1)
|
||||||
|
adapter.dump_window_hierarchy()
|
||||||
|
adapter.capture_screenshot()
|
||||||
|
adapter.leave_final_submit_panel_once()
|
||||||
|
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
|
||||||
|
self.assertEqual(methods, ["dumpWindowHierarchy", "takeScreenshot", "pressKey"])
|
||||||
|
self.assertEqual(adapter.page_control_action_attempts, 0)
|
||||||
|
with self.assertRaises(FinalSubmitPanelError):
|
||||||
|
adapter.leave_final_submit_panel_once()
|
||||||
|
self.assertEqual(methods.count("pressKey"), 1)
|
||||||
|
|
||||||
|
def test_adapter_seals_back_before_timeout(self) -> None:
|
||||||
|
raw = FakeRawDevice(back_timeout=True)
|
||||||
|
adapter = UiautomatorFinalSubmitPanelAdapter(raw, FakeForegroundReader(), SERIAL, 0.1)
|
||||||
|
with self.assertRaises(FinalSubmitPanelTimeoutError):
|
||||||
|
adapter.leave_final_submit_panel_once()
|
||||||
|
self.assertEqual(adapter.back_attempts, 1)
|
||||||
|
self.assertEqual(adapter.back_outcome, "ambiguous")
|
||||||
|
with self.assertRaises(FinalSubmitPanelError):
|
||||||
|
adapter.leave_final_submit_panel_once()
|
||||||
|
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
|
||||||
|
self.assertEqual(methods.count("pressKey"), 1)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _load_cli() -> object:
|
||||||
|
path = CLIENT_ROOT / "scripts" / "run_t107_final_submit_panel_dry_run.py"
|
||||||
|
spec = spec_from_file_location("run_t107_final_submit_panel_for_test", path)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise AssertionError("cannot load CLI")
|
||||||
|
module = module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class FinalSubmitPanelCliTests(unittest.TestCase):
|
||||||
|
def test_cli_has_no_submit_or_mode_switch(self) -> None:
|
||||||
|
module = _load_cli()
|
||||||
|
parser_args = module.parse_arguments(
|
||||||
|
[
|
||||||
|
"--serial", SERIAL,
|
||||||
|
"--goods-id", EXPECTED_GOODS_ID,
|
||||||
|
"--color", TASK_COLOR,
|
||||||
|
"--size", TASK_SIZE,
|
||||||
|
"--quantity", "2",
|
||||||
|
"--gate1-unit-price", "12.88",
|
||||||
|
"--gate2-panel-total-price", "32.76",
|
||||||
|
"--gate2-screenshot", "gate2.png",
|
||||||
|
"--gate2-captured-at", "2026-08-06T00:53:00+00:00",
|
||||||
|
"--max-total-price", "40.00",
|
||||||
|
"--output-dir", "gate3-output",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.assertFalse(hasattr(parser_args, "allow_submit"))
|
||||||
|
self.assertFalse(hasattr(parser_args, "dry_run"))
|
||||||
|
|
||||||
|
def test_cli_failure_is_fixed_and_does_not_echo_sensitive_inputs(self) -> None:
|
||||||
|
module = _load_cli()
|
||||||
|
secret = "private-serial"
|
||||||
|
stream = StringIO()
|
||||||
|
with redirect_stderr(stream):
|
||||||
|
code = module.main(
|
||||||
|
[
|
||||||
|
"--serial", secret,
|
||||||
|
"--goods-id", "1",
|
||||||
|
"--color", TASK_COLOR,
|
||||||
|
"--size", TASK_SIZE,
|
||||||
|
"--quantity", "2",
|
||||||
|
"--gate1-unit-price", "12.88",
|
||||||
|
"--gate2-panel-total-price", "32.76",
|
||||||
|
"--gate2-screenshot", "missing.png",
|
||||||
|
"--gate2-captured-at", "2026-08-06T00:53:00+00:00",
|
||||||
|
"--max-total-price", "40.00",
|
||||||
|
"--output-dir", "gate3-output",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.assertEqual(code, 2)
|
||||||
|
self.assertNotIn(secret, stream.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
class FinalSubmitPanelStaticBoundaryTests(unittest.TestCase):
|
||||||
|
def test_protocol_exposes_only_reads_and_named_single_back(self) -> None:
|
||||||
|
public = {name for name in FinalSubmitPanelDevice.__dict__ if not name.startswith("_")}
|
||||||
|
self.assertEqual(
|
||||||
|
public,
|
||||||
|
{
|
||||||
|
"app_info",
|
||||||
|
"current_foreground",
|
||||||
|
"display_size",
|
||||||
|
"dump_window_hierarchy",
|
||||||
|
"capture_screenshot",
|
||||||
|
"leave_final_submit_panel_once",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_observer_is_pure_and_accepts_no_device(self) -> None:
|
||||||
|
parameters = set(inspect.signature(observe_gate3).parameters)
|
||||||
|
self.assertEqual(parameters, {"raw_hierarchy", "gate2", "screenshot_path", "captured_at"})
|
||||||
|
source = (CLIENT_ROOT / "src/cmbuyer_client/pdd/final_submit_panel.py").read_text(encoding="utf-8")
|
||||||
|
self.assertNotIn("uiautomator", source)
|
||||||
|
self.assertNotIn("jsonrpc", source)
|
||||||
|
|
||||||
|
def test_production_rpc_literals_are_reads_plus_back_only(self) -> None:
|
||||||
|
path = CLIENT_ROOT / "src/cmbuyer_client/pdd/final_submit_panel_runner.py"
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||||
|
methods: set[str] = set()
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.Call) or len(node.args) < 2:
|
||||||
|
continue
|
||||||
|
if not isinstance(node.func, ast.Attribute) or node.func.attr != "_call":
|
||||||
|
continue
|
||||||
|
first, second = node.args[:2]
|
||||||
|
if isinstance(first, ast.Constant) and first.value == "jsonrpc_call" and isinstance(second, ast.Constant):
|
||||||
|
methods.add(second.value)
|
||||||
|
self.assertEqual(methods, {"dumpWindowHierarchy", "takeScreenshot", "pressKey"})
|
||||||
|
|
||||||
|
def test_imports_and_sources_have_no_forbidden_submission_capabilities(self) -> None:
|
||||||
|
paths = (
|
||||||
|
CLIENT_ROOT / "src/cmbuyer_client/pdd/final_submit_panel.py",
|
||||||
|
CLIENT_ROOT / "src/cmbuyer_client/pdd/final_submit_panel_runner.py",
|
||||||
|
CLIENT_ROOT / "scripts/run_t107_final_submit_panel_dry_run.py",
|
||||||
|
)
|
||||||
|
forbidden = (
|
||||||
|
"SubmissionPermit",
|
||||||
|
"submit_order_once",
|
||||||
|
"click_permitted",
|
||||||
|
"go_to_order_confirm",
|
||||||
|
"allow_submit",
|
||||||
|
"dry_run=False",
|
||||||
|
"submission_fence",
|
||||||
|
)
|
||||||
|
for path in paths:
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(source)
|
||||||
|
imported = " ".join(
|
||||||
|
alias.name
|
||||||
|
for node in ast.walk(tree)
|
||||||
|
if isinstance(node, (ast.Import, ast.ImportFrom))
|
||||||
|
for alias in node.names
|
||||||
|
)
|
||||||
|
with self.subTest(path=path):
|
||||||
|
self.assertFalse(any(token.lower() in imported.lower() for token in ("fence", "payment", "result_sink")))
|
||||||
|
for token in forbidden:
|
||||||
|
self.assertNotIn(token, source)
|
||||||
|
|
||||||
|
def test_fixtures_contain_no_unrelated_personal_or_payment_data(self) -> None:
|
||||||
|
combined = PANEL_FIXTURE.read_text(encoding="utf-8") + EXIT_FIXTURE.read_text(encoding="utf-8")
|
||||||
|
for forbidden in ("手机号", "收货地址", "支付密码", "微信支付", "支付宝"):
|
||||||
|
self.assertNotIn(forbidden, combined)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -461,6 +461,14 @@ events/fail/fence/result 或完整 `ResultSink`。T-304/T-306 必须通过 `Dura
|
|||||||
T-103 只实现隔离的 `SkuSelectionFlow`:前四项加安全退出。它的模块和静态依赖不得引用数量、最终提交面板、
|
T-103 只实现隔离的 `SkuSelectionFlow`:前四项加安全退出。它的模块和静态依赖不得引用数量、最终提交面板、
|
||||||
围栏、提交或支付能力。后续任务按取证顺序组合成生产 `SinglePassPurchaseFlow`。
|
围栏、提交或支付能力。后续任务按取证顺序组合成生产 `SinglePassPurchaseFlow`。
|
||||||
|
|
||||||
|
T-107 已实现的 `observe_gate3()` 是无 device 的纯 XML observer,只接受可信 `Gate2Observation`、当前
|
||||||
|
XML、显式截图路径和带时区采集时间。它分别重读当前面板顶部 `gate2_panel_total_price` 与最终完整文本
|
||||||
|
`提交订单 ¥{gate3_submit_amount}`,要求两者唯一、规范、严格相等且不超 `max_total_price`;同时精确
|
||||||
|
复核目标规格摘要和数量 2。成功 DTO 只包含规格/数量、两个独立金额、完整文本、匹配数、启用态、
|
||||||
|
“最近可点击祖先唯一”布尔值、截图路径和采集时间,不包含 selector、bounds、XML 节点、祖先句柄或
|
||||||
|
可操作对象。`FinalSubmitPanelRunner` 永久是围栏前 dry-run:RPC 仅允许截图、XML 和一次 `pressKey Back`,
|
||||||
|
不接受提交开关,不上传证据;Back 结果不明或返回页未连续两帧命中 T-106 同商品正判据时不重试。
|
||||||
|
|
||||||
## 四、实现前仍需定值
|
## 四、实现前仍需定值
|
||||||
|
|
||||||
- 授权有效期、领取租约时长、心跳/轮询间隔和连续失败停止阈值;
|
- 授权有效期、领取租约时长、心跳/轮询间隔和连续失败停止阈值;
|
||||||
|
|||||||
+14
-1
@@ -70,7 +70,7 @@
|
|||||||
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
||||||
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
||||||
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含建单/授权/详情/证据/设备身份与原子 claim/renew;不执行真机动作 |
|
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含建单/授权/详情/证据/设备身份与原子 claim/renew;不执行真机动作 |
|
||||||
| `client/` | 已初始化 | Python 3.11+、PySide6/uiautomator2、固定双 Tab 主界面、安全轮询、严格 HTTP/DPAPI/SQLite 恢复底座及受控规格选择/读价/数量 Gate2/安全退出;T-106 合并式最终面板与一次 Back 返回两态只读取证已完成,最终提交仍未开放 |
|
| `client/` | 已初始化 | Python 3.11+、PySide6/uiautomator2、固定双 Tab 主界面、安全轮询、严格 HTTP/DPAPI/SQLite 恢复底座及受控规格选择/读价/数量 Gate2/安全退出;T-106 两态取证已完成,T-107 Gate3 纯 observer 与一次 Back runner 已实现并等待真机人工验收,最终提交仍未开放 |
|
||||||
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
|
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
|
||||||
|
|
||||||
## 任务状态
|
## 任务状态
|
||||||
@@ -252,6 +252,19 @@ T-106 第一态只读取证已于 2026-08-06 完成。脚本重新核对设备
|
|||||||
Back、未点击提交订单、未创建待付款订单、未进入付款/安全验证/外部页面。T-106 已完成;随后 T-107
|
Back、未点击提交订单、未创建待付款订单、未进入付款/安全验证/外部页面。T-106 已完成;随后 T-107
|
||||||
固化零页面点击的 Gate3 observer 和一次安全 Back。红色控件仍不得点击。
|
固化零页面点击的 Gate3 observer 和一次安全 Back。红色控件仍不得点击。
|
||||||
|
|
||||||
|
T-107 代码已从上述两态证据提取最小 fixture:纯 XML observer 分别复核顶部 Gate2、规格摘要、数量 2、
|
||||||
|
最终完整文本 Gate3 及最近可点击祖先;DTO 不暴露节点/坐标/selector。runner 在观察成功前零页面动作,
|
||||||
|
成功后只发送一次 Android Back,并要求返回页连续两帧命中完整商品标题和底部入口正锚;结果不明不重试。
|
||||||
|
真机人工准备同一最终面板后,从仓库根运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\run_t107_final_submit_panel_dry_run.py --serial 192.168.0.173:5555 --goods-id 937122477375 --color "黑色CHA(纯棉)" --size "M(建议100-115)" --quantity 2 --gate1-unit-price 12.88 --gate2-panel-total-price 32.76 --gate2-screenshot "$env:LOCALAPPDATA\cmbuyer\artifacts\T-105\quantity-gate2-live-937122477375-20260806\gate2_screenshot.png" --gate2-captured-at "2026-08-06T01:33:33.818765+00:00" --max-total-price 40.00 --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-107\final-submit-panel-dry-run-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
该命令会重新核对当前拼多多版本、前台应用、设备和屏幕,不会点击红色“提交订单”,也不会申请围栏、
|
||||||
|
创建待付款订单或进入付款。T-107 保持 `DOING`,等待项目所有者确认 Gate2/Gate3 均为 `32.76`、
|
||||||
|
完整文本 match count=1、最近可点击祖先唯一、一次 Back 安全,且未创建订单/进入付款。
|
||||||
|
|
||||||
## 关键背景
|
## 关键背景
|
||||||
|
|
||||||
本项目是 `cmroubao`(Go 后端 + Android AccessibilityService)与 `cmpdd`
|
本项目是 `cmroubao`(Go 后端 + Android AccessibilityService)与 `cmpdd`
|
||||||
|
|||||||
+9
-1
@@ -22,7 +22,7 @@ write_paths:
|
|||||||
- docs/current-state.md
|
- docs/current-state.md
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- BEGIN VIKUNJA EXPORT id=42 synced=2026-08-06T02:52:05Z sha256=fa2a65bb48ad33c6179d848e4265fb1db8584d73b3cf986b71cc38cec05a00d6 -->
|
<!-- BEGIN VIKUNJA EXPORT id=42 synced=2026-08-06T04:08:33Z sha256=abf49f164d13d9ff9707ef41e4e55b11a4e267d7accfe6f8c177e0bf92373309 -->
|
||||||
## 问题 / 背景
|
## 问题 / 背景
|
||||||
|
|
||||||
T-106 真机证据证明当前拼多多 8.17.0 使用合并式最终提交面板:没有独立确认页导航;面板顶部是 Gate2 总额,红色“提交订单 ¥金额”会直接创建待付款订单。T-107 只固化该稳定面板的 Gate3 只读金额、最终控件观察与一次安全返回;绝不点击最终提交。
|
T-106 真机证据证明当前拼多多 8.17.0 使用合并式最终提交面板:没有独立确认页导航;面板顶部是 Gate2 总额,红色“提交订单 ¥金额”会直接创建待付款订单。T-107 只固化该稳定面板的 Gate3 只读金额、最终控件观察与一次安全返回;绝不点击最终提交。
|
||||||
@@ -60,6 +60,14 @@ T-106 真机证据证明当前拼多多 8.17.0 使用合并式最终提交面板
|
|||||||
### 2026-08-04T14:04:47Z · ila
|
### 2026-08-04T14:04:47Z · ila
|
||||||
|
|
||||||
2026-08-04 预研定值:T-107 只消费 T-106 四态证据;确认页导航最多一次,最终提交控件由无 device 的纯 observer 读取且 DTO 不暴露节点/坐标/selector,随后最多一次 Back 并验证返回后置条件。全路径静态不可达提交与付款。
|
2026-08-04 预研定值:T-107 只消费 T-106 四态证据;确认页导航最多一次,最终提交控件由无 device 的纯 observer 读取且 DTO 不暴露节点/坐标/selector,随后最多一次 Back 并验证返回后置条件。全路径静态不可达提交与付款。
|
||||||
|
|
||||||
|
### 2026-08-06T03:26:59Z · ila
|
||||||
|
|
||||||
|
2026-08-06 · Codex 已领取 T-107,基线 commit=dbf9b8c,独立 worktree/branch=task/t-107-confirm-gate3。完整 init.ps1 基线通过(admin test/vet/build、client 346 tests、compileall、上下文门禁)。本任务只从 T-106 两态真机证据固化 Gate3 纯 XML observer 与一次安全 Back;成功轨迹页面控件点击数必须为 0,绝不点击“提交订单”,不接触围栏、订单创建或付款能力。代码完成后保持 DOING,等待项目所有者真机人工验收。
|
||||||
|
|
||||||
|
### 2026-08-06T04:08:25Z · ila
|
||||||
|
|
||||||
|
2026-08-06 T-107 实现候选完成:基于 T-106 人工确认的拼多多 8.17.0 / goods_id 937122477375 两态原始 XML,实现纯 XML Gate3 observer、零页面控件动作 dry-run runner、成功后唯一一次 Android Back、连续两帧同商品返回正判据及本地原子证据。最终提交控件只读,未引入 fence/permit/submit/payment 能力。fixture/静态边界/金额/结构/一次返回等 36 项专项测试通过;client 全量 382 项 unittest、compileall、admin go test/vet/build、完整 init.ps1、agent-context 校验均通过;两份 T-106 原始 XML 离线复核通过。任务因 needs_device/needs_human_review 保持 DOING,等待项目所有者按任务命令确认 Gate2=Gate3=32.76、match_count=1、最近可点击祖先唯一、仅一次 Back,且未创建订单/进入付款。
|
||||||
<!-- END VIKUNJA EXPORT -->
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|||||||
Reference in New Issue
Block a user