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`:前四项加安全退出。它的模块和静态依赖不得引用数量、最终提交面板、
|
||||
围栏、提交或支付能力。后续任务按取证顺序组合成生产 `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 采购执行 / 配置;均已人工确认 |
|
||||
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
||||
| `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 缺工具链明确失败 |
|
||||
|
||||
## 任务状态
|
||||
@@ -252,6 +252,19 @@ T-106 第一态只读取证已于 2026-08-06 完成。脚本重新核对设备
|
||||
Back、未点击提交订单、未创建待付款订单、未进入付款/安全验证/外部页面。T-106 已完成;随后 T-107
|
||||
固化零页面点击的 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`
|
||||
|
||||
+9
-1
@@ -22,7 +22,7 @@ write_paths:
|
||||
- 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 只读金额、最终控件观察与一次安全返回;绝不点击最终提交。
|
||||
@@ -60,6 +60,14 @@ T-106 真机证据证明当前拼多多 8.17.0 使用合并式最终提交面板
|
||||
### 2026-08-04T14:04:47Z · ila
|
||||
|
||||
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 -->
|
||||
|
||||
## 边界
|
||||
|
||||
Reference in New Issue
Block a user