feat: 正式采购下单前更新地址标记 (#173)
This commit is contained in:
@@ -78,7 +78,11 @@ class PddPurchaseAdapter(ABC):
|
|||||||
|
|
||||||
|
|
||||||
class PddLivePurchaseAdapter(PddPurchaseAdapter):
|
class PddLivePurchaseAdapter(PddPurchaseAdapter):
|
||||||
"""受控真实采购接口;只增加一次性提交,不提供付款或取消。"""
|
"""受控真实采购接口;增加地址标记和一次性提交,不提供付款或取消。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def update_shipping_address(self, purchase_number: str) -> None:
|
||||||
|
"""下单前更新地址末尾采购编号;失败时必须保持在可逆阶段。"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def submit_order_once(self) -> None:
|
def submit_order_once(self) -> None:
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ from .util.select_color_size import select_color, select_size
|
|||||||
Bounds = tuple[int, int, int, int]
|
Bounds = tuple[int, int, int, int]
|
||||||
_BOUNDS_PATTERN = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$")
|
_BOUNDS_PATTERN = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$")
|
||||||
_PRICE_PATTERN = re.compile(r"[¥¥]\s*(\d+(?:\.\d{1,2})?)")
|
_PRICE_PATTERN = re.compile(r"[¥¥]\s*(\d+(?:\.\d{1,2})?)")
|
||||||
|
_MASKED_PHONE_PATTERN = re.compile(r"(?<!\d)\d{3}\*{4}\d{4}(?!\d)")
|
||||||
|
_PROGRAM_ADDRESS_SUFFIX_PATTERN = re.compile(
|
||||||
|
r"(?:[-_](?:cg\d+|(?:am|pm)?[0-9a-f]{4,12}))$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
_READY_MARKERS = ("发起拼单", "立即购买", "单独购买", "免拼购买", "快要抢光")
|
_READY_MARKERS = ("发起拼单", "立即购买", "单独购买", "免拼购买", "快要抢光")
|
||||||
_LOGIN_MARKERS = ("手机号登录", "登录后继续", "验证码登录", "账号登录")
|
_LOGIN_MARKERS = ("手机号登录", "登录后继续", "验证码登录", "账号登录")
|
||||||
_CAPTCHA_MARKERS = ("请完成验证", "拖动滑块", "安全验证", "点击图中")
|
_CAPTCHA_MARKERS = ("请完成验证", "拖动滑块", "安全验证", "点击图中")
|
||||||
@@ -150,6 +155,107 @@ def _parse_bounds(value: str) -> Optional[Bounds]:
|
|||||||
return left, top, right, bottom
|
return left, top, right, bottom
|
||||||
|
|
||||||
|
|
||||||
|
def _parent_nodes(root: ET.Element) -> dict[ET.Element, ET.Element]:
|
||||||
|
return {
|
||||||
|
child: parent
|
||||||
|
for parent in root.iter()
|
||||||
|
for child in parent
|
||||||
|
if child.tag == "node"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _nearest_clickable_bounds(
|
||||||
|
node: ET.Element, parents: Mapping[ET.Element, ET.Element]
|
||||||
|
) -> Optional[Bounds]:
|
||||||
|
"""从文字节点向上寻找最近的可点击、可见且启用容器。"""
|
||||||
|
|
||||||
|
current: Optional[ET.Element] = node
|
||||||
|
while current is not None:
|
||||||
|
bounds = _parse_bounds(current.get("bounds", ""))
|
||||||
|
if (
|
||||||
|
bounds is not None
|
||||||
|
and current.get("clickable") == "true"
|
||||||
|
and current.get("enabled", "true") != "false"
|
||||||
|
and current.get("visible-to-user", "true") != "false"
|
||||||
|
):
|
||||||
|
return bounds
|
||||||
|
current = parents.get(current)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _clickable_text_targets(root: ET.Element, exact_text: str) -> list[Bounds]:
|
||||||
|
"""按明确文字返回最近的唯一点击容器候选。"""
|
||||||
|
|
||||||
|
parents = _parent_nodes(root)
|
||||||
|
targets = {
|
||||||
|
bounds
|
||||||
|
for node in root.iter("node")
|
||||||
|
if exact_text
|
||||||
|
in {
|
||||||
|
node.get("text", "").strip(),
|
||||||
|
node.get("content-desc", "").strip(),
|
||||||
|
}
|
||||||
|
if (bounds := _nearest_clickable_bounds(node, parents)) is not None
|
||||||
|
}
|
||||||
|
return sorted(targets)
|
||||||
|
|
||||||
|
|
||||||
|
def _address_entry_targets(root: ET.Element) -> list[Bounds]:
|
||||||
|
"""通过脱敏手机号结构定位规格面板顶部的收货地址卡片。"""
|
||||||
|
|
||||||
|
parents = _parent_nodes(root)
|
||||||
|
targets = {
|
||||||
|
bounds
|
||||||
|
for node in root.iter("node")
|
||||||
|
if _MASKED_PHONE_PATTERN.search(_label(node))
|
||||||
|
if (bounds := _nearest_clickable_bounds(node, parents)) is not None
|
||||||
|
}
|
||||||
|
return sorted(targets)
|
||||||
|
|
||||||
|
|
||||||
|
def _shipping_address_editor(root: ET.Element) -> Optional[tuple[str, Bounds]]:
|
||||||
|
"""在修改页中寻找与“详细地址”标签同一行的唯一输入框。"""
|
||||||
|
|
||||||
|
address_labels = [
|
||||||
|
bounds
|
||||||
|
for node in root.iter("node")
|
||||||
|
if "详细地址" in _label(node).replace(" ", "")
|
||||||
|
if (bounds := _parse_bounds(node.get("bounds", ""))) is not None
|
||||||
|
]
|
||||||
|
editors = [
|
||||||
|
(node.get("text", ""), bounds)
|
||||||
|
for node in root.iter("node")
|
||||||
|
if node.get("class") == "android.widget.EditText"
|
||||||
|
if node.get("enabled", "true") != "false"
|
||||||
|
if node.get("visible-to-user", "true") != "false"
|
||||||
|
if (bounds := _parse_bounds(node.get("bounds", ""))) is not None
|
||||||
|
]
|
||||||
|
matches = [
|
||||||
|
editor
|
||||||
|
for editor in editors
|
||||||
|
for label_bounds in address_labels
|
||||||
|
if editor[1][1] <= label_bounds[3]
|
||||||
|
and editor[1][3] >= label_bounds[1]
|
||||||
|
and editor[1][0] >= label_bounds[0]
|
||||||
|
]
|
||||||
|
return matches[0] if len(matches) == 1 and matches[0][0].strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _tagged_shipping_address(address: str, purchase_number: str) -> str:
|
||||||
|
"""只替换程序生成的末尾标记,保留完整真实地址主体。"""
|
||||||
|
|
||||||
|
checked_address = str(address or "").strip()
|
||||||
|
checked_number = str(purchase_number or "").strip()
|
||||||
|
if not checked_address or not checked_number:
|
||||||
|
raise ValueError("地址和采购编号不能为空")
|
||||||
|
if any(character in checked_number for character in "\r\n\t"):
|
||||||
|
raise ValueError("采购编号不能包含控制字符")
|
||||||
|
address_body = _PROGRAM_ADDRESS_SUFFIX_PATTERN.sub("", checked_address)
|
||||||
|
if not address_body:
|
||||||
|
raise ValueError("地址主体不能为空")
|
||||||
|
return f"{address_body}_{checked_number}"
|
||||||
|
|
||||||
|
|
||||||
def _goods_id_from_url(goods_url: str) -> str:
|
def _goods_id_from_url(goods_url: str) -> str:
|
||||||
parsed = urlparse(goods_url)
|
parsed = urlparse(goods_url)
|
||||||
host = (parsed.hostname or "").lower()
|
host = (parsed.hostname or "").lower()
|
||||||
@@ -1090,6 +1196,156 @@ def create_u2_purchase_adapter(
|
|||||||
class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter):
|
class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter):
|
||||||
"""只允许一次最终提交点击的真机 Adapter,不包含付款路径。"""
|
"""只允许一次最终提交点击的真机 Adapter,不包含付款路径。"""
|
||||||
|
|
||||||
|
def update_shipping_address(self, purchase_number: str) -> None:
|
||||||
|
"""更新正式采购地址末尾标记,并返回原规格确认面板。"""
|
||||||
|
|
||||||
|
self._check_cancelled("purchase_update_shipping_address")
|
||||||
|
device = self._require_device()
|
||||||
|
try:
|
||||||
|
checked_number = str(purchase_number or "").strip()
|
||||||
|
if not checked_number or any(
|
||||||
|
character in checked_number for character in "\r\n\t"
|
||||||
|
):
|
||||||
|
raise PddPurchaseError(
|
||||||
|
"PURCHASE_NUMBER_INVALID",
|
||||||
|
"采购编号为空或包含无效字符,未修改收货地址",
|
||||||
|
step="purchase_update_shipping_address",
|
||||||
|
)
|
||||||
|
|
||||||
|
root = _parse_xml(self._dump_hierarchy())
|
||||||
|
entry_targets = _address_entry_targets(root)
|
||||||
|
self._click_unique_address_target(
|
||||||
|
entry_targets,
|
||||||
|
"PURCHASE_ADDRESS_ENTRY_AMBIGUOUS",
|
||||||
|
"规格面板没有唯一可靠的收货地址入口",
|
||||||
|
)
|
||||||
|
|
||||||
|
panel_root = self._wait_for_address_page("panel")
|
||||||
|
modify_targets = _clickable_text_targets(panel_root, "修改")
|
||||||
|
self._click_unique_address_target(
|
||||||
|
modify_targets,
|
||||||
|
"PURCHASE_ADDRESS_EDIT_AMBIGUOUS",
|
||||||
|
"收货地址页没有唯一可靠的修改按钮",
|
||||||
|
)
|
||||||
|
|
||||||
|
edit_root = self._wait_for_address_page("edit")
|
||||||
|
editor_data = _shipping_address_editor(edit_root)
|
||||||
|
if editor_data is None:
|
||||||
|
raise PddPurchaseError(
|
||||||
|
"PURCHASE_ADDRESS_EDITOR_MISSING",
|
||||||
|
"修改页没有唯一可靠的详细地址输入框",
|
||||||
|
step="purchase_update_shipping_address",
|
||||||
|
)
|
||||||
|
current_address, _editor_bounds = editor_data
|
||||||
|
try:
|
||||||
|
new_address = _tagged_shipping_address(
|
||||||
|
current_address, checked_number
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise PddPurchaseError(
|
||||||
|
"PURCHASE_ADDRESS_VALUE_INVALID",
|
||||||
|
"当前详细地址或采购编号不符合更新要求",
|
||||||
|
step="purchase_update_shipping_address",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
editor = device(
|
||||||
|
className="android.widget.EditText", text=current_address
|
||||||
|
)
|
||||||
|
if int(getattr(editor, "count", 0)) != 1:
|
||||||
|
raise PddPurchaseError(
|
||||||
|
"PURCHASE_ADDRESS_EDITOR_AMBIGUOUS",
|
||||||
|
"无法唯一选中详细地址输入框",
|
||||||
|
step="purchase_update_shipping_address",
|
||||||
|
)
|
||||||
|
editor.set_text(new_address)
|
||||||
|
self._sleep(0.2)
|
||||||
|
|
||||||
|
latest_edit_root = _parse_xml(self._dump_hierarchy())
|
||||||
|
latest_editor = _shipping_address_editor(latest_edit_root)
|
||||||
|
if latest_editor is None or latest_editor[0] != new_address:
|
||||||
|
raise PddPurchaseError(
|
||||||
|
"PURCHASE_ADDRESS_INPUT_MISMATCH",
|
||||||
|
"详细地址输入后回读不一致,未保存也未下单",
|
||||||
|
step="purchase_update_shipping_address",
|
||||||
|
)
|
||||||
|
save_targets = _clickable_text_targets(latest_edit_root, "保存")
|
||||||
|
self._click_unique_address_target(
|
||||||
|
save_targets,
|
||||||
|
"PURCHASE_ADDRESS_SAVE_AMBIGUOUS",
|
||||||
|
"修改页没有唯一可靠的保存按钮",
|
||||||
|
)
|
||||||
|
|
||||||
|
saved_root = self._wait_for_address_page(
|
||||||
|
"saved", expected_address=new_address
|
||||||
|
)
|
||||||
|
back_targets = _clickable_text_targets(saved_root, "返回")
|
||||||
|
self._click_unique_address_target(
|
||||||
|
back_targets,
|
||||||
|
"PURCHASE_ADDRESS_BACK_AMBIGUOUS",
|
||||||
|
"收货地址页没有唯一可靠的返回按钮",
|
||||||
|
)
|
||||||
|
self._wait_for_address_page("confirmation")
|
||||||
|
except PddPurchaseError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
self._raise_device_or_page_error(
|
||||||
|
exc, "purchase_update_shipping_address"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _click_unique_address_target(
|
||||||
|
self, targets: list[Bounds], code: str, message: str
|
||||||
|
) -> None:
|
||||||
|
"""地址流程只允许点击唯一候选,避免误触其他资料。"""
|
||||||
|
|
||||||
|
if len(targets) != 1:
|
||||||
|
raise PddPurchaseError(
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
step="purchase_update_shipping_address",
|
||||||
|
diagnostics={"candidate_count": len(targets)},
|
||||||
|
)
|
||||||
|
left, top, right, bottom = targets[0]
|
||||||
|
self._require_device().click(
|
||||||
|
(left + right) // 2, (top + bottom) // 2
|
||||||
|
)
|
||||||
|
|
||||||
|
def _wait_for_address_page(
|
||||||
|
self, kind: str, *, expected_address: str = ""
|
||||||
|
) -> ET.Element:
|
||||||
|
"""等待地址页面切换;诊断信息不包含任何地址文字。"""
|
||||||
|
|
||||||
|
deadline = self._monotonic() + self._panel_timeout
|
||||||
|
while self._monotonic() < deadline:
|
||||||
|
self._check_cancelled("purchase_update_shipping_address")
|
||||||
|
root = _parse_xml(self._dump_hierarchy())
|
||||||
|
labels = _labels(root)
|
||||||
|
if kind == "panel" and "收货地址" in labels:
|
||||||
|
return root
|
||||||
|
if kind == "edit" and any(
|
||||||
|
"修改收货地址" in label.replace(" ", "")
|
||||||
|
for label in labels
|
||||||
|
):
|
||||||
|
return root
|
||||||
|
if (
|
||||||
|
kind == "saved"
|
||||||
|
and "收货地址" in labels
|
||||||
|
and expected_address
|
||||||
|
and any(expected_address in label for label in labels)
|
||||||
|
):
|
||||||
|
return root
|
||||||
|
if (
|
||||||
|
kind == "confirmation"
|
||||||
|
and _page_kind(root, "") == "order_confirmation"
|
||||||
|
):
|
||||||
|
return root
|
||||||
|
self._sleep(0.2)
|
||||||
|
raise PddPurchaseError(
|
||||||
|
"PURCHASE_ADDRESS_PAGE_TIMEOUT",
|
||||||
|
"收货地址页面切换或保存确认超时,未进入不可逆下单阶段",
|
||||||
|
step="purchase_update_shipping_address",
|
||||||
|
diagnostics={"expected_page": kind},
|
||||||
|
)
|
||||||
|
|
||||||
def submit_order_once(self) -> None:
|
def submit_order_once(self) -> None:
|
||||||
if self._submit_attempted:
|
if self._submit_attempted:
|
||||||
raise PddPurchaseError(
|
raise PddPurchaseError(
|
||||||
|
|||||||
@@ -166,6 +166,16 @@ class PurchaseTaskService:
|
|||||||
state = adapter.read_state()
|
state = adapter.read_state()
|
||||||
self._validate_checkout_values(state, target)
|
self._validate_checkout_values(state, target)
|
||||||
|
|
||||||
|
if execution_mode == "live":
|
||||||
|
assert isinstance(adapter, PddLivePurchaseAdapter)
|
||||||
|
step = "purchase_update_shipping_address"
|
||||||
|
self._enter_step(remote_task_id, started.attempt_id, step)
|
||||||
|
adapter.update_shipping_address(remote_task_id)
|
||||||
|
|
||||||
|
step = "purchase_verify_after_address"
|
||||||
|
self._enter_step(remote_task_id, started.attempt_id, step)
|
||||||
|
self._validate_checkout_values(adapter.read_state(), target)
|
||||||
|
|
||||||
step = "purchase_enter_confirmation"
|
step = "purchase_enter_confirmation"
|
||||||
self._enter_step(remote_task_id, started.attempt_id, step)
|
self._enter_step(remote_task_id, started.attempt_id, step)
|
||||||
self._validate_checkout_values(adapter.read_state(), target)
|
self._validate_checkout_values(adapter.read_state(), target)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from src.pdd_u2_purchase_adapter import (
|
|||||||
U2PddPurchaseAdapter,
|
U2PddPurchaseAdapter,
|
||||||
_final_submit_targets,
|
_final_submit_targets,
|
||||||
_page_kind,
|
_page_kind,
|
||||||
|
_tagged_shipping_address,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -239,6 +240,114 @@ class ContextualConfirmPanelDevice(FakeDevice):
|
|||||||
self.mode = "panel"
|
self.mode = "panel"
|
||||||
|
|
||||||
|
|
||||||
|
def address_confirmation_xml(address: str) -> str:
|
||||||
|
return f"""<hierarchy>
|
||||||
|
<node package="com.xunmeng.pinduoduo" bounds="[0,0][1080,2376]">
|
||||||
|
<node clickable="true" enabled="true" visible-to-user="true"
|
||||||
|
bounds="[0,300][1080,520]">
|
||||||
|
<node text="测试用户,000****0000" bounds="[300,330][800,380]"/>
|
||||||
|
<node text="{address}" bounds="[120,390][1000,470]"/>
|
||||||
|
</node>
|
||||||
|
<node text="已选: 黑色 3XL【140-165斤】" bounds="[300,550][1000,610]"/>
|
||||||
|
<node content-desc="黑色" selected="true" bounds="[30,900][330,1000]"/>
|
||||||
|
<node text="3XL【140-165斤】" selected="true" bounds="[40,1200][500,1300]"/>
|
||||||
|
<node text="折后¥5.03" bounds="[300,530][600,590]"/>
|
||||||
|
<node class="android.widget.EditText" text="1" bounds="[480,700][560,780]"/>
|
||||||
|
<node content-desc="减少数量" clickable="true" bounds="[390,700][470,780]"/>
|
||||||
|
<node content-desc="增加数量" clickable="true" bounds="[570,700][650,780]"/>
|
||||||
|
<node clickable="true" enabled="true" visible-to-user="true"
|
||||||
|
bounds="[0,2181][1080,2328]">
|
||||||
|
<node text="提交订单 ¥5.03" enabled="true" visible-to-user="true"
|
||||||
|
bounds="[380,2220][700,2290]"/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</hierarchy>"""
|
||||||
|
|
||||||
|
|
||||||
|
def address_panel_xml(address: str) -> str:
|
||||||
|
return f"""<hierarchy>
|
||||||
|
<node package="com.xunmeng.pinduoduo" bounds="[0,0][1080,2376]">
|
||||||
|
<node clickable="true" enabled="true" bounds="[0,120][240,258]">
|
||||||
|
<node text="返回" bounds="[42,154][108,223]"/>
|
||||||
|
</node>
|
||||||
|
<node text="收货地址" bounds="[438,120][642,258]"/>
|
||||||
|
<node text="{address}" bounds="[36,400][900,590]"/>
|
||||||
|
<node clickable="true" enabled="true" bounds="[900,600][1040,700]">
|
||||||
|
<node text="修改" bounds="[930,620][1020,680]"/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</hierarchy>"""
|
||||||
|
|
||||||
|
|
||||||
|
def address_edit_xml(address: str) -> str:
|
||||||
|
return f"""<hierarchy>
|
||||||
|
<node package="com.xunmeng.pinduoduo" bounds="[0,0][1080,2376]">
|
||||||
|
<node text="修改收货地址" bounds="[380,350][700,450]"/>
|
||||||
|
<node text="收货人" bounds="[120,550][260,610]"/>
|
||||||
|
<node class="android.widget.EditText" text="测试用户"
|
||||||
|
enabled="true" visible-to-user="true" bounds="[327,510][906,654]"/>
|
||||||
|
<node text="手机号码" bounds="[120,700][260,760]"/>
|
||||||
|
<node class="android.widget.EditText" text="00000000000"
|
||||||
|
enabled="true" visible-to-user="true" bounds="[327,654][906,798]"/>
|
||||||
|
<node text="详细地址" bounds="[120,990][300,1043]"/>
|
||||||
|
<node class="android.widget.EditText" text="{address}"
|
||||||
|
enabled="true" visible-to-user="true" bounds="[327,990][813,1116]"/>
|
||||||
|
<node clickable="true" enabled="true" bounds="[120,1194][960,1329]">
|
||||||
|
<node text="保存" bounds="[489,1232][591,1291]"/>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</hierarchy>"""
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAddressEditor:
|
||||||
|
def __init__(self, device, matched: bool) -> None:
|
||||||
|
self.device = device
|
||||||
|
self.count = 1 if matched else 0
|
||||||
|
|
||||||
|
def set_text(self, value: str) -> None:
|
||||||
|
self.device.address = value
|
||||||
|
|
||||||
|
|
||||||
|
class AddressFlowDevice(FakeDevice):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.address = "测试省测试市测试区测试路1号-1dd212"
|
||||||
|
|
||||||
|
def dump_hierarchy(self):
|
||||||
|
if not self.has_opened:
|
||||||
|
return '<hierarchy><node package="com.xunmeng.pinduoduo" /></hierarchy>'
|
||||||
|
if self.mode == "home":
|
||||||
|
return home_xml()
|
||||||
|
if self.mode == "panel":
|
||||||
|
return address_confirmation_xml(self.address)
|
||||||
|
if self.mode in {"address_panel", "address_saved"}:
|
||||||
|
return address_panel_xml(self.address)
|
||||||
|
if self.mode == "address_edit":
|
||||||
|
return address_edit_xml(self.address)
|
||||||
|
raise AssertionError(f"未知测试页面:{self.mode}")
|
||||||
|
|
||||||
|
def click(self, x, y):
|
||||||
|
self.clicks.append((x, y))
|
||||||
|
if self.mode == "home":
|
||||||
|
self.mode = "panel"
|
||||||
|
elif self.mode == "panel" and y < 600:
|
||||||
|
self.mode = "address_panel"
|
||||||
|
elif self.mode == "address_panel":
|
||||||
|
self.mode = "address_edit"
|
||||||
|
elif self.mode == "address_edit":
|
||||||
|
self.mode = "address_saved"
|
||||||
|
elif self.mode == "address_saved":
|
||||||
|
self.mode = "panel"
|
||||||
|
|
||||||
|
def __call__(self, **selector):
|
||||||
|
if (
|
||||||
|
selector.get("className") == "android.widget.EditText"
|
||||||
|
and "text" in selector
|
||||||
|
):
|
||||||
|
return FakeAddressEditor(self, selector["text"] == self.address)
|
||||||
|
return super().__call__(**selector)
|
||||||
|
|
||||||
|
|
||||||
class U2PddPurchaseAdapterTest(unittest.TestCase):
|
class U2PddPurchaseAdapterTest(unittest.TestCase):
|
||||||
def _adapter(self, device, calls):
|
def _adapter(self, device, calls):
|
||||||
def select_color_fn(_device, _xml, target, **_kwargs):
|
def select_color_fn(_device, _xml, target, **_kwargs):
|
||||||
@@ -383,6 +492,47 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
|
|||||||
self.assertEqual(device.clicks[-1], (540, 2140))
|
self.assertEqual(device.clicks[-1], (540, 2140))
|
||||||
adapter.close()
|
adapter.close()
|
||||||
|
|
||||||
|
def test_program_address_suffix_is_replaced_without_cutting_body(self):
|
||||||
|
self.assertEqual(
|
||||||
|
_tagged_shipping_address(
|
||||||
|
"测试省测试市测试区测试路1号-1dd212", "cg2"
|
||||||
|
),
|
||||||
|
"测试省测试市测试区测试路1号_cg2",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
_tagged_shipping_address(
|
||||||
|
"测试省测试市测试区测试路1号_cg2", "cg3"
|
||||||
|
),
|
||||||
|
"测试省测试市测试区测试路1号_cg3",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
_tagged_shipping_address(
|
||||||
|
"测试省-开发区测试路1号", "cg4"
|
||||||
|
),
|
||||||
|
"测试省-开发区测试路1号_cg4",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_live_adapter_updates_address_and_returns_to_confirmation(self):
|
||||||
|
device = AddressFlowDevice()
|
||||||
|
adapter = self._live_adapter(device, [])
|
||||||
|
adapter.open_goods(GOODS_URL)
|
||||||
|
adapter.select_options(
|
||||||
|
{"color": "黑色", "size": "3XL【140-165斤】"}
|
||||||
|
)
|
||||||
|
adapter.set_quantity(1)
|
||||||
|
|
||||||
|
adapter.update_shipping_address("cg2")
|
||||||
|
state = adapter.read_state()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
device.address, "测试省测试市测试区测试路1号_cg2"
|
||||||
|
)
|
||||||
|
self.assertEqual(device.mode, "panel")
|
||||||
|
self.assertEqual(state.page_kind, "order_confirmation")
|
||||||
|
self.assertEqual(state.quantity, 1)
|
||||||
|
self.assertEqual(state.price_cent, 503)
|
||||||
|
adapter.close()
|
||||||
|
|
||||||
def test_reliable_pdd_tree_skips_repeated_app_current(self):
|
def test_reliable_pdd_tree_skips_repeated_app_current(self):
|
||||||
device = FakeDevice()
|
device = FakeDevice()
|
||||||
adapter = self._adapter(device, [])
|
adapter = self._adapter(device, [])
|
||||||
|
|||||||
@@ -92,11 +92,27 @@ class RecordingDryRunAdapter(PddPurchaseAdapter):
|
|||||||
class RecordingLiveAdapter(RecordingDryRunAdapter, PddLivePurchaseAdapter):
|
class RecordingLiveAdapter(RecordingDryRunAdapter, PddLivePurchaseAdapter):
|
||||||
"""只记录一次提交调用的 live 测试 Adapter。"""
|
"""只记录一次提交调用的 live 测试 Adapter。"""
|
||||||
|
|
||||||
def __init__(self, *, submit_error: bool = False, **kwargs) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
submit_error: bool = False,
|
||||||
|
address_error: bool = False,
|
||||||
|
**kwargs,
|
||||||
|
) -> None:
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.submit_error = submit_error
|
self.submit_error = submit_error
|
||||||
|
self.address_error = address_error
|
||||||
self.submit_count = 0
|
self.submit_count = 0
|
||||||
|
|
||||||
|
def update_shipping_address(self, purchase_number: str) -> None:
|
||||||
|
self.calls.append(("update_shipping_address", purchase_number))
|
||||||
|
if self.address_error:
|
||||||
|
raise PddPurchaseError(
|
||||||
|
"PURCHASE_ADDRESS_PAGE_TIMEOUT",
|
||||||
|
"地址页面切换超时",
|
||||||
|
step="purchase_update_shipping_address",
|
||||||
|
)
|
||||||
|
|
||||||
def submit_order_once(self) -> None:
|
def submit_order_once(self) -> None:
|
||||||
self.submit_count += 1
|
self.submit_count += 1
|
||||||
self.calls.append(("submit_order_once",))
|
self.calls.append(("submit_order_once",))
|
||||||
@@ -324,6 +340,7 @@ class PurchaseTaskServiceTest(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(outcome.kind, "reconcile_pending")
|
self.assertEqual(outcome.kind, "reconcile_pending")
|
||||||
self.assertEqual(adapter.submit_count, 1)
|
self.assertEqual(adapter.submit_count, 1)
|
||||||
|
self.assertIn(("update_shipping_address", "PUR-001"), adapter.calls)
|
||||||
detail = self.repository.get_task("PUR-001")
|
detail = self.repository.get_task("PUR-001")
|
||||||
run = self.repository.latest_task_run("PUR-001")
|
run = self.repository.latest_task_run("PUR-001")
|
||||||
self.assertEqual(detail.status, TaskStatus.MANUAL_REVIEW)
|
self.assertEqual(detail.status, TaskStatus.MANUAL_REVIEW)
|
||||||
@@ -344,6 +361,21 @@ class PurchaseTaskServiceTest(unittest.TestCase):
|
|||||||
run = self.repository.latest_task_run("PUR-001")
|
run = self.repository.latest_task_run("PUR-001")
|
||||||
self.assertIsNone(run.irreversible_action_at)
|
self.assertIsNone(run.irreversible_action_at)
|
||||||
|
|
||||||
|
def test_live_address_failure_stops_before_irreversible(self):
|
||||||
|
self._prepare_task(execution_mode="live")
|
||||||
|
adapter = RecordingLiveAdapter(address_error=True)
|
||||||
|
|
||||||
|
outcome = self._service(adapter).execute_one_local()
|
||||||
|
|
||||||
|
self.assertEqual(outcome.kind, "task_failed")
|
||||||
|
self.assertEqual(adapter.submit_count, 0)
|
||||||
|
run = self.repository.latest_task_run("PUR-001")
|
||||||
|
self.assertIsNone(run.irreversible_action_at)
|
||||||
|
detail = self.repository.get_task("PUR-001")
|
||||||
|
self.assertEqual(
|
||||||
|
detail.last_error_code, "PURCHASE_ADDRESS_PAGE_TIMEOUT"
|
||||||
|
)
|
||||||
|
|
||||||
def test_live_submit_error_never_retries_and_still_enters_reconcile(self):
|
def test_live_submit_error_never_retries_and_still_enters_reconcile(self):
|
||||||
self._prepare_task(execution_mode="live")
|
self._prepare_task(execution_mode="live")
|
||||||
adapter = RecordingLiveAdapter(submit_error=True)
|
adapter = RecordingLiveAdapter(submit_error=True)
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ class ReadyPurchaseAdapter(PddPurchaseAdapter):
|
|||||||
|
|
||||||
|
|
||||||
class ReadyLivePurchaseAdapter(ReadyPurchaseAdapter, PddLivePurchaseAdapter):
|
class ReadyLivePurchaseAdapter(ReadyPurchaseAdapter, PddLivePurchaseAdapter):
|
||||||
|
def update_shipping_address(self, purchase_number):
|
||||||
|
self.calls.append(("purchase", "address", purchase_number))
|
||||||
|
|
||||||
def read_state(self):
|
def read_state(self):
|
||||||
state = super().read_state()
|
state = super().read_state()
|
||||||
return PurchasePageState(
|
return PurchasePageState(
|
||||||
|
|||||||
@@ -80,10 +80,16 @@ Client 应执行:
|
|||||||
|
|
||||||
1. 校验商品、规格、数量、库存和价格保护条件。
|
1. 校验商品、规格、数量、库存和价格保护条件。
|
||||||
2. 选择指定颜色、尺码和数量。
|
2. 选择指定颜色、尺码和数量。
|
||||||
3. 进入订单提交前状态并再次校验价格。
|
3. 正式采购使用远程任务编号更新收货地址末尾的采购编号标记,演练流程不修改地址。
|
||||||
4. 在允许真实下单时提交订单。
|
4. 返回订单提交前状态并再次校验商品、规格、数量和价格。
|
||||||
5. 获取并核对订单编号和下单时间。
|
5. 在允许真实下单时提交订单。
|
||||||
6. 保存本地结果并提交 Admin。
|
6. 获取并核对订单编号和下单时间。
|
||||||
|
7. 保存本地结果并提交 Admin。
|
||||||
|
|
||||||
|
地址更新只允许替换程序生成的末尾标记,不能截断真实地址主体。地址入口、修改按钮、
|
||||||
|
详细地址输入框、保存按钮或保存结果任一项不唯一、不完整或无法回读时,任务必须在
|
||||||
|
不可逆标记写入前失败并停止。姓名、手机号、完整地址和原始控件树只用于本次设备会话,
|
||||||
|
不得写入 SQLite、日志、Gitea、测试固件或文档。
|
||||||
|
|
||||||
仅凭“我的订单”列表中的最新一条记录不能认定为当前任务订单。订单页必须读到
|
仅凭“我的订单”列表中的最新一条记录不能认定为当前任务订单。订单页必须读到
|
||||||
非空订单编号、有效下单时间和待付款状态;下单时间须位于本地
|
非空订单编号、有效下单时间和待付款状态;下单时间须位于本地
|
||||||
|
|||||||
@@ -401,8 +401,11 @@ reconcile_purchase(task, run) -> PurchaseResult | ManualReview
|
|||||||
采购演练通过 `PddPurchaseAdapter` 的窄接口逐步读取最新页面状态。该接口只提供
|
采购演练通过 `PddPurchaseAdapter` 的窄接口逐步读取最新页面状态。该接口只提供
|
||||||
打开商品、读取状态、精确选择动态规格、设置数量、进入提交前确认页和停止,
|
打开商品、读取状态、精确选择动态规格、设置数量、进入提交前确认页和停止,
|
||||||
**不提供提交订单或付款方法**。这样即使应用层调用错误,也没有可误触的真实下单入口。
|
**不提供提交订单或付款方法**。这样即使应用层调用错误,也没有可误触的真实下单入口。
|
||||||
真实采购另用 `PddLivePurchaseAdapter`,只增加 `submit_order_once`。服务层在最新
|
真实采购另用 `PddLivePurchaseAdapter`,只增加下单前地址标记更新和
|
||||||
页面复核规格、数量、价格、库存和唯一提交目标后,先提交
|
`submit_order_once`。地址标记使用任务现有的 `remote_task_id`,只替换程序生成的
|
||||||
|
末尾标记;修改、保存和回读任一步不可靠时在可逆阶段停止。地址只在设备会话内处理,
|
||||||
|
不进入数据库、日志、诊断产物或接口结果。服务层在地址更新后重新读取最新页面,
|
||||||
|
复核规格、数量、价格、库存和唯一提交目标后,先提交
|
||||||
`irreversible_action_at` 事务,再允许 Adapter 点击一次;之后无论点击结果是否明确,
|
`irreversible_action_at` 事务,再允许 Adapter 点击一次;之后无论点击结果是否明确,
|
||||||
都只进入订单核对。该接口不提供付款或取消订单方法。
|
都只进入订单核对。该接口不提供付款或取消订单方法。
|
||||||
采购规格使用完整 `options` 对象精确比较,不假定只有颜色和尺码两个维度。
|
采购规格使用完整 `options` 对象精确比较,不假定只有颜色和尺码两个维度。
|
||||||
|
|||||||
Reference in New Issue
Block a user