fix: 稳定地址表单后单次保存 (#191)
This commit is contained in:
@@ -1410,19 +1410,9 @@ class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter):
|
|||||||
step="purchase_update_shipping_address",
|
step="purchase_update_shipping_address",
|
||||||
)
|
)
|
||||||
editor.set_text(new_address)
|
editor.set_text(new_address)
|
||||||
self._sleep(0.2)
|
save_target = self._wait_for_stable_address_save(new_address)
|
||||||
|
|
||||||
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(
|
self._click_unique_address_target(
|
||||||
save_targets,
|
[save_target],
|
||||||
"PURCHASE_ADDRESS_SAVE_AMBIGUOUS",
|
"PURCHASE_ADDRESS_SAVE_AMBIGUOUS",
|
||||||
"修改页没有唯一可靠的保存按钮",
|
"修改页没有唯一可靠的保存按钮",
|
||||||
)
|
)
|
||||||
@@ -1552,6 +1542,90 @@ class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter):
|
|||||||
diagnostics=diagnostics,
|
diagnostics=diagnostics,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _wait_for_stable_address_save(self, expected_address: str) -> Bounds:
|
||||||
|
"""地址和唯一保存目标连续稳定后,返回最终复核过的点击位置。"""
|
||||||
|
|
||||||
|
deadline = self._monotonic() + self._panel_timeout
|
||||||
|
stable_target: Optional[Bounds] = None
|
||||||
|
stable_reads = 0
|
||||||
|
candidate_count = 0
|
||||||
|
observed_page = "unknown"
|
||||||
|
address_matches = False
|
||||||
|
|
||||||
|
while self._monotonic() < deadline:
|
||||||
|
self._check_cancelled("purchase_update_shipping_address")
|
||||||
|
root = _parse_xml(self._dump_hierarchy())
|
||||||
|
observed_page = _address_page_kind(root)
|
||||||
|
editor = _shipping_address_editor(root)
|
||||||
|
address_matches = editor is not None and editor[0] == expected_address
|
||||||
|
save_targets = _clickable_text_targets(root, "保存")
|
||||||
|
candidate_count = len(save_targets)
|
||||||
|
|
||||||
|
ready = (
|
||||||
|
observed_page == "edit"
|
||||||
|
and address_matches
|
||||||
|
and candidate_count == 1
|
||||||
|
)
|
||||||
|
if ready and save_targets[0] == stable_target:
|
||||||
|
stable_reads += 1
|
||||||
|
elif ready:
|
||||||
|
stable_target = save_targets[0]
|
||||||
|
stable_reads = 1
|
||||||
|
else:
|
||||||
|
stable_target = None
|
||||||
|
stable_reads = 0
|
||||||
|
|
||||||
|
if stable_reads >= 2 and stable_target is not None:
|
||||||
|
# 给表单一次短暂提交准备时间,然后必须用最新控件树再验证。
|
||||||
|
self._sleep(0.3)
|
||||||
|
latest_root = _parse_xml(self._dump_hierarchy())
|
||||||
|
latest_editor = _shipping_address_editor(latest_root)
|
||||||
|
latest_targets = _clickable_text_targets(latest_root, "保存")
|
||||||
|
latest_ready = (
|
||||||
|
_address_page_kind(latest_root) == "edit"
|
||||||
|
and latest_editor is not None
|
||||||
|
and latest_editor[0] == expected_address
|
||||||
|
and latest_targets == [stable_target]
|
||||||
|
)
|
||||||
|
if latest_ready:
|
||||||
|
return stable_target
|
||||||
|
stable_target = None
|
||||||
|
stable_reads = 0
|
||||||
|
observed_page = _address_page_kind(latest_root)
|
||||||
|
address_matches = (
|
||||||
|
latest_editor is not None
|
||||||
|
and latest_editor[0] == expected_address
|
||||||
|
)
|
||||||
|
candidate_count = len(latest_targets)
|
||||||
|
|
||||||
|
self._sleep(0.2)
|
||||||
|
|
||||||
|
diagnostics: dict[str, Any] = {
|
||||||
|
"expected_page": "edit_ready_to_save",
|
||||||
|
"observed_page": observed_page,
|
||||||
|
"address_matches": address_matches,
|
||||||
|
"save_candidate_count": candidate_count,
|
||||||
|
"stable_reads": stable_reads,
|
||||||
|
}
|
||||||
|
artifact = self._save_last_xml("purchase-address-save-not-ready")
|
||||||
|
if artifact is not None:
|
||||||
|
diagnostics["artifacts"] = [artifact]
|
||||||
|
input_mismatch = observed_page == "edit" and not address_matches
|
||||||
|
raise PddPurchaseError(
|
||||||
|
(
|
||||||
|
"PURCHASE_ADDRESS_INPUT_MISMATCH"
|
||||||
|
if input_mismatch
|
||||||
|
else "PURCHASE_ADDRESS_SAVE_NOT_READY"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"详细地址输入后回读不一致,未保存也未下单"
|
||||||
|
if input_mismatch
|
||||||
|
else "地址编辑页尚未稳定,未点击保存也未下单"
|
||||||
|
),
|
||||||
|
step="purchase_update_shipping_address",
|
||||||
|
diagnostics=diagnostics,
|
||||||
|
)
|
||||||
|
|
||||||
def _wait_for_address_page(
|
def _wait_for_address_page(
|
||||||
self, kind: str, *, expected_address: str = ""
|
self, kind: str, *, expected_address: str = ""
|
||||||
) -> ET.Element:
|
) -> ET.Element:
|
||||||
@@ -1641,9 +1715,18 @@ class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter):
|
|||||||
artifact = self._save_last_xml("purchase-address-saved-timeout")
|
artifact = self._save_last_xml("purchase-address-saved-timeout")
|
||||||
if artifact is not None:
|
if artifact is not None:
|
||||||
diagnostics["artifacts"] = [artifact]
|
diagnostics["artifacts"] = [artifact]
|
||||||
|
save_not_effective = observed_page == "edit"
|
||||||
raise PddPurchaseError(
|
raise PddPurchaseError(
|
||||||
"PURCHASE_ADDRESS_PAGE_TIMEOUT",
|
(
|
||||||
"收货地址页面切换或保存确认超时,未进入不可逆下单阶段",
|
"PURCHASE_ADDRESS_SAVE_NOT_EFFECTIVE"
|
||||||
|
if save_not_effective
|
||||||
|
else "PURCHASE_ADDRESS_PAGE_TIMEOUT"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"点击保存后仍停留在地址编辑页,未进入不可逆下单阶段"
|
||||||
|
if save_not_effective
|
||||||
|
else "收货地址页面切换或保存确认超时,未进入不可逆下单阶段"
|
||||||
|
),
|
||||||
step="purchase_update_shipping_address",
|
step="purchase_update_shipping_address",
|
||||||
diagnostics=diagnostics,
|
diagnostics=diagnostics,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -616,6 +616,85 @@ class HiddenSavedAddressDevice(AddressFlowDevice):
|
|||||||
return super().dump_hierarchy()
|
return super().dump_hierarchy()
|
||||||
|
|
||||||
|
|
||||||
|
class DelayedAddressEditor(FakeAddressEditor):
|
||||||
|
"""先记录输入,等待控件树刷新后才显示新地址。"""
|
||||||
|
|
||||||
|
def set_text(self, value: str) -> None:
|
||||||
|
self.device.pending_address = value
|
||||||
|
|
||||||
|
|
||||||
|
class DelayedStableSaveTargetDevice(AddressFlowDevice):
|
||||||
|
"""输入完成后保存目标先变化,随后才稳定。"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.save_form_reads = 0
|
||||||
|
self.save_clicks = 0
|
||||||
|
self.pending_address = None
|
||||||
|
|
||||||
|
def dump_hierarchy(self):
|
||||||
|
if self.mode != "address_edit" or self.pending_address is None:
|
||||||
|
return super().dump_hierarchy()
|
||||||
|
self.save_form_reads += 1
|
||||||
|
if self.save_form_reads >= 2:
|
||||||
|
self.address = self.pending_address
|
||||||
|
xml_data = super().dump_hierarchy()
|
||||||
|
if not self.address.endswith("_cg2"):
|
||||||
|
return xml_data
|
||||||
|
shift = min(self.save_form_reads, 3) * 10
|
||||||
|
return xml_data.replace(
|
||||||
|
'bounds="[120,1194][960,1329]"',
|
||||||
|
f'bounds="[{120 + shift},1194][{960 + shift},1329]"',
|
||||||
|
)
|
||||||
|
|
||||||
|
def click(self, x, y):
|
||||||
|
if self.mode == "address_edit":
|
||||||
|
self.save_clicks += 1
|
||||||
|
super().click(x, y)
|
||||||
|
|
||||||
|
def __call__(self, **selector):
|
||||||
|
if (
|
||||||
|
selector.get("className") == "android.widget.EditText"
|
||||||
|
and "text" in selector
|
||||||
|
):
|
||||||
|
return DelayedAddressEditor(self, selector["text"] == self.address)
|
||||||
|
return super().__call__(**selector)
|
||||||
|
|
||||||
|
|
||||||
|
class NeverStableSaveTargetDevice(DelayedStableSaveTargetDevice):
|
||||||
|
"""保存目标在两个位置之间持续变化。"""
|
||||||
|
|
||||||
|
def dump_hierarchy(self):
|
||||||
|
if self.mode != "address_edit" or self.pending_address is None:
|
||||||
|
return AddressFlowDevice.dump_hierarchy(self)
|
||||||
|
self.save_form_reads += 1
|
||||||
|
if self.save_form_reads >= 2:
|
||||||
|
self.address = self.pending_address
|
||||||
|
xml_data = AddressFlowDevice.dump_hierarchy(self)
|
||||||
|
if not self.address.endswith("_cg2"):
|
||||||
|
return xml_data
|
||||||
|
shift = 10 if self.save_form_reads % 2 else 30
|
||||||
|
return xml_data.replace(
|
||||||
|
'bounds="[120,1194][960,1329]"',
|
||||||
|
f'bounds="[{120 + shift},1194][{960 + shift},1329]"',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LostSaveClickAddressDevice(AddressFlowDevice):
|
||||||
|
"""保存按钮点击一次后页面仍停留在编辑页。"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.save_clicks = 0
|
||||||
|
|
||||||
|
def click(self, x, y):
|
||||||
|
if self.mode == "address_edit":
|
||||||
|
self.clicks.append((x, y))
|
||||||
|
self.save_clicks += 1
|
||||||
|
return
|
||||||
|
super().click(x, y)
|
||||||
|
|
||||||
|
|
||||||
class LostFirstModifyClickAddressDevice(AddressFlowDevice):
|
class LostFirstModifyClickAddressDevice(AddressFlowDevice):
|
||||||
"""模拟第一次“修改”点击被页面吞掉。"""
|
"""模拟第一次“修改”点击被页面吞掉。"""
|
||||||
|
|
||||||
@@ -1071,6 +1150,83 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
|
|||||||
self.assertEqual(device.presses, [])
|
self.assertEqual(device.presses, [])
|
||||||
adapter.close()
|
adapter.close()
|
||||||
|
|
||||||
|
def test_address_save_waits_for_stable_target_before_single_click(self):
|
||||||
|
clock = FakeClock()
|
||||||
|
device = DelayedStableSaveTargetDevice()
|
||||||
|
adapter = self._live_adapter(
|
||||||
|
device,
|
||||||
|
[],
|
||||||
|
sleeper=clock.sleep,
|
||||||
|
monotonic=clock.monotonic,
|
||||||
|
)
|
||||||
|
adapter.open_goods(GOODS_URL)
|
||||||
|
adapter.select_options(
|
||||||
|
{"color": "黑色", "size": "3XL【140-165斤】"}
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter.update_shipping_address("cg2")
|
||||||
|
|
||||||
|
self.assertGreaterEqual(device.save_form_reads, 5)
|
||||||
|
self.assertEqual(device.save_clicks, 1)
|
||||||
|
self.assertEqual(device.mode, "panel")
|
||||||
|
self.assertGreaterEqual(clock.now, 0.7)
|
||||||
|
adapter.close()
|
||||||
|
|
||||||
|
def test_unstable_address_save_target_is_never_clicked(self):
|
||||||
|
clock = FakeClock()
|
||||||
|
device = NeverStableSaveTargetDevice()
|
||||||
|
expected_address = "测试省测试市测试区测试路1号_cg2"
|
||||||
|
adapter = self._live_adapter(
|
||||||
|
device,
|
||||||
|
[],
|
||||||
|
sleeper=clock.sleep,
|
||||||
|
monotonic=clock.monotonic,
|
||||||
|
panel_timeout=1.0,
|
||||||
|
)
|
||||||
|
adapter.open_goods(GOODS_URL)
|
||||||
|
adapter.select_options(
|
||||||
|
{"color": "黑色", "size": "3XL【140-165斤】"}
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(PddPurchaseError) as raised:
|
||||||
|
adapter.update_shipping_address("cg2")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
raised.exception.code,
|
||||||
|
"PURCHASE_ADDRESS_SAVE_NOT_READY",
|
||||||
|
)
|
||||||
|
self.assertEqual(device.save_clicks, 0)
|
||||||
|
self.assertNotIn(expected_address, repr(raised.exception.diagnostics))
|
||||||
|
adapter.close()
|
||||||
|
|
||||||
|
def test_lost_save_click_reports_not_effective_without_retry(self):
|
||||||
|
clock = FakeClock()
|
||||||
|
device = LostSaveClickAddressDevice()
|
||||||
|
expected_address = "测试省测试市测试区测试路1号_cg2"
|
||||||
|
adapter = self._live_adapter(
|
||||||
|
device,
|
||||||
|
[],
|
||||||
|
sleeper=clock.sleep,
|
||||||
|
monotonic=clock.monotonic,
|
||||||
|
panel_timeout=1.0,
|
||||||
|
)
|
||||||
|
adapter.open_goods(GOODS_URL)
|
||||||
|
adapter.select_options(
|
||||||
|
{"color": "黑色", "size": "3XL【140-165斤】"}
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(PddPurchaseError) as raised:
|
||||||
|
adapter.update_shipping_address("cg2")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
raised.exception.code,
|
||||||
|
"PURCHASE_ADDRESS_SAVE_NOT_EFFECTIVE",
|
||||||
|
)
|
||||||
|
self.assertEqual(raised.exception.diagnostics["observed_page"], "edit")
|
||||||
|
self.assertEqual(device.save_clicks, 1)
|
||||||
|
self.assertNotIn(expected_address, repr(raised.exception.diagnostics))
|
||||||
|
adapter.close()
|
||||||
|
|
||||||
def test_coloros_overlay_can_disappear_without_system_back(self):
|
def test_coloros_overlay_can_disappear_without_system_back(self):
|
||||||
device = AddressSaveOverlayDevice(clear_after_reads=2)
|
device = AddressSaveOverlayDevice(clear_after_reads=2)
|
||||||
adapter = self._live_adapter(device, [])
|
adapter = self._live_adapter(device, [])
|
||||||
|
|||||||
Reference in New Issue
Block a user