fix(client): accept exact current-only sku state
This commit is contained in:
@@ -18,6 +18,7 @@ import cmbuyer_client.pdd.sku_selection_runner as runner_module
|
||||
from cmbuyer_client.device.adb import AdbDevice, DeviceConnectionError, DeviceInspection
|
||||
from cmbuyer_client.pdd import SkuSelectionError, SkuSelectionFlow, SkuSelectionRunner
|
||||
from cmbuyer_client.pdd.sku_selection import (
|
||||
_PanelProfile,
|
||||
SkuPanelDevice,
|
||||
_action_bounds,
|
||||
_classify_panel,
|
||||
@@ -36,6 +37,8 @@ from cmbuyer_client.pdd.sku_selection_runner import (
|
||||
_FIXTURES = Path(__file__).with_name("fixtures")
|
||||
_EMPTY_FIXTURE = _FIXTURES / "sku_panel_empty_8_17_0.xml"
|
||||
_COLOR_FIXTURE = _FIXTURES / "sku_panel_color_selected_size_hidden_8_17_0.xml"
|
||||
_CURRENT_ONLY_EMPTY_FIXTURE = _FIXTURES / "sku_panel_empty_current_only_8_17_0.xml"
|
||||
_CURRENT_ONLY_COLOR_FIXTURE = _FIXTURES / "sku_panel_color_selected_size_hidden_current_only_8_17_0.xml"
|
||||
_S_FIXTURE = _FIXTURES / "sku_panel_size_s_selected_8_17_0.xml"
|
||||
_M_FIXTURE = _FIXTURES / "sku_panel_size_m_restored_8_17_0.xml"
|
||||
_FIXTURE = _M_FIXTURE
|
||||
@@ -57,6 +60,7 @@ class _RawDevice:
|
||||
def __init__(self, hierarchy: str = _PRODUCT_PAGE, screenshot: str | None = None) -> None:
|
||||
self.hierarchy = hierarchy
|
||||
self.panel_hierarchy = _EMPTY_FIXTURE.read_text(encoding="utf-8")
|
||||
self.color_hierarchy = _COLOR_FIXTURE.read_text(encoding="utf-8")
|
||||
self.version = "8.17.0"
|
||||
self.package = "com.xunmeng.pinduoduo"
|
||||
self.screenshot = _png_base64() if screenshot is None else screenshot
|
||||
@@ -99,7 +103,7 @@ class _RawDevice:
|
||||
self.hierarchy = (
|
||||
_EMPTY_FIXTURE.read_text(encoding="utf-8")
|
||||
if self.fail_color_readback
|
||||
else _COLOR_FIXTURE.read_text(encoding="utf-8")
|
||||
else self.color_hierarchy
|
||||
)
|
||||
return
|
||||
if (x, y) == (635, 1624):
|
||||
@@ -177,6 +181,63 @@ def _mutate_unique_node(
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _nested_dual_price_layout(
|
||||
hierarchy: str,
|
||||
*,
|
||||
header_bounds: str,
|
||||
row_bounds: str,
|
||||
summary_bounds: str,
|
||||
content_bounds: str,
|
||||
price_frame_bounds: str,
|
||||
) -> str:
|
||||
root = ElementTree.fromstring(hierarchy)
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
header = next(
|
||||
node for node in root.iter("node")
|
||||
if node.get("class") == "android.widget.LinearLayout"
|
||||
and node.get("bounds") == header_bounds
|
||||
)
|
||||
row = next(
|
||||
node for node in root.iter("node")
|
||||
if node.get("class") == "android.widget.LinearLayout"
|
||||
and node.get("bounds") == row_bounds
|
||||
)
|
||||
summary = next(
|
||||
node for node in root.iter("node")
|
||||
if node.get("class") == "android.widget.TextView"
|
||||
and node.get("bounds") == summary_bounds
|
||||
)
|
||||
parents[row].remove(row)
|
||||
parents[summary].remove(summary)
|
||||
|
||||
def inert(class_name: str, bounds: str) -> ElementTree.Element:
|
||||
return ElementTree.Element(
|
||||
"node",
|
||||
{
|
||||
"package": "com.xunmeng.pinduoduo",
|
||||
"class": class_name,
|
||||
"bounds": bounds,
|
||||
"clickable": "false",
|
||||
"enabled": "true",
|
||||
"visible-to-user": "true",
|
||||
"selected": "false",
|
||||
"scrollable": "false",
|
||||
},
|
||||
)
|
||||
|
||||
content_frame = inert("android.widget.FrameLayout", content_bounds)
|
||||
relative = inert("android.widget.RelativeLayout", content_bounds)
|
||||
content = inert("android.view.ViewGroup", content_bounds)
|
||||
price_frame = inert("android.widget.FrameLayout", price_frame_bounds)
|
||||
header.append(content_frame)
|
||||
content_frame.append(relative)
|
||||
relative.append(content)
|
||||
content.append(price_frame)
|
||||
price_frame.append(row)
|
||||
content.append(summary)
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _entry_chain(root: ElementTree.Element) -> list[ElementTree.Element]:
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
child = next(node for node in root.iter("node") if node.get("text") == "快要抢光 ¥ 12.88")
|
||||
@@ -364,6 +425,169 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
self.assertEqual(_tap_centers(restored), [(635, 1624)])
|
||||
self.assertEqual(_actions(restored, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
|
||||
|
||||
def test_current_only_empty_to_color_only_uses_the_matching_exact_spec(self) -> None:
|
||||
device = _RawDevice()
|
||||
device.panel_hierarchy = _CURRENT_ONLY_EMPTY_FIXTURE.read_text(encoding="utf-8")
|
||||
device.color_hierarchy = _CURRENT_ONLY_COLOR_FIXTURE.read_text(encoding="utf-8")
|
||||
flow = _flow(device)
|
||||
|
||||
flow.open_sku_panel(_TARGET_URL)
|
||||
with self.assertRaisesRegex(SkuSelectionError, "受控显示动作尚未取证"):
|
||||
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||
|
||||
self.assertEqual(_tap_centers(device), [(865, 2218), (528, 1387)])
|
||||
matched = _classify_panel(_parse_nodes(device.hierarchy))
|
||||
self.assertIs(matched.profile, _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN)
|
||||
self.assertEqual(
|
||||
matched.price_layout.__class__.__name__,
|
||||
"_CurrentOnlyPriceLayout",
|
||||
)
|
||||
|
||||
def test_dual_and_current_only_variants_each_match_one_complete_spec(self) -> None:
|
||||
cases = (
|
||||
(_EMPTY_FIXTURE, _PanelProfile.PANEL_OPEN_EMPTY, "_DualPriceLayout"),
|
||||
(_COLOR_FIXTURE, _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN, "_DualPriceLayout"),
|
||||
(_CURRENT_ONLY_EMPTY_FIXTURE, _PanelProfile.PANEL_OPEN_EMPTY, "_CurrentOnlyPriceLayout"),
|
||||
(_CURRENT_ONLY_COLOR_FIXTURE, _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN, "_CurrentOnlyPriceLayout"),
|
||||
)
|
||||
for fixture, profile, layout_name in cases:
|
||||
with self.subTest(fixture=fixture.name):
|
||||
spec = _classify_panel(_parse_nodes(fixture.read_text(encoding="utf-8")))
|
||||
self.assertIs(spec.profile, profile)
|
||||
self.assertEqual(spec.price_layout.__class__.__name__, layout_name)
|
||||
|
||||
def test_dual_price_historical_nested_parent_chains_remain_accepted(self) -> None:
|
||||
for fixture, expected_profile in (
|
||||
(_EMPTY_FIXTURE, _PanelProfile.PANEL_OPEN_EMPTY),
|
||||
(_COLOR_FIXTURE, _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN),
|
||||
):
|
||||
with self.subTest(fixture=fixture.name):
|
||||
nested = _nested_dual_price_layout(
|
||||
fixture.read_text(encoding="utf-8"),
|
||||
header_bounds="[0,366][1080,1077]",
|
||||
row_bounds="[396,575][912,647]",
|
||||
summary_bounds="[396,731][1053,793]",
|
||||
content_bounds="[0,551][1080,940]",
|
||||
price_frame_bounds="[396,575][1053,647]",
|
||||
)
|
||||
spec = _classify_panel(_parse_nodes(nested))
|
||||
self.assertIs(spec.profile, expected_profile)
|
||||
self.assertEqual(spec.price_layout.__class__.__name__, "_DualPriceLayout")
|
||||
|
||||
nested_s = _nested_dual_price_layout(
|
||||
_S_FIXTURE.read_text(encoding="utf-8"),
|
||||
header_bounds="[0,366][1080,1000]",
|
||||
row_bounds="[396,498][895,570]",
|
||||
summary_bounds="[396,654][1053,716]",
|
||||
content_bounds="[0,474][1080,863]",
|
||||
price_frame_bounds="[396,498][1053,570]",
|
||||
)
|
||||
self.assertIs(
|
||||
_classify_panel(_parse_nodes(nested_s)).profile,
|
||||
_PanelProfile.SIZE_VISIBLE_NON_TARGET,
|
||||
)
|
||||
|
||||
nested_m = _nested_dual_price_layout(
|
||||
_M_FIXTURE.read_text(encoding="utf-8"),
|
||||
header_bounds="[0,366][1080,1000]",
|
||||
row_bounds="[396,498][895,570]",
|
||||
summary_bounds="[396,654][1053,716]",
|
||||
content_bounds="[0,474][1080,863]",
|
||||
price_frame_bounds="[396,498][1053,570]",
|
||||
)
|
||||
self.assertEqual(_flow(_RawDevice(nested_m)).read_sku_unit_price(), "12.88")
|
||||
|
||||
def test_current_only_price_variant_rejects_cross_variant_duplicate_unknown_and_parent_drift(self) -> None:
|
||||
base = _CURRENT_ONLY_EMPTY_FIXTURE.read_text(encoding="utf-8")
|
||||
|
||||
def mutate(kind: str) -> str:
|
||||
root = ElementTree.fromstring(base)
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
frame = next(
|
||||
node for node in root.iter("node")
|
||||
if node.get("class") == "android.widget.FrameLayout"
|
||||
and node.get("bounds") == "[396,575][1053,647]"
|
||||
)
|
||||
row = next(
|
||||
node for node in frame
|
||||
if node.get("class") == "android.widget.LinearLayout"
|
||||
and node.get("bounds") == "[396,575][693,647]"
|
||||
)
|
||||
current = next(node for node in row if node.get("text") == "限1件 ¥12.88 ")
|
||||
if kind == "cross_variant":
|
||||
ElementTree.SubElement(
|
||||
row,
|
||||
"node",
|
||||
{
|
||||
"text": "券前¥29.88",
|
||||
"package": "com.xunmeng.pinduoduo",
|
||||
"class": "android.widget.TextView",
|
||||
"bounds": "[693,580][912,647]",
|
||||
"clickable": "false",
|
||||
"enabled": "true",
|
||||
"visible-to-user": "true",
|
||||
"selected": "false",
|
||||
"scrollable": "false",
|
||||
},
|
||||
)
|
||||
elif kind == "duplicate_frame":
|
||||
parents[frame].append(ElementTree.fromstring(ElementTree.tostring(frame, encoding="unicode")))
|
||||
elif kind == "duplicate_current":
|
||||
row.append(ElementTree.fromstring(ElementTree.tostring(current, encoding="unicode")))
|
||||
elif kind == "unknown_text":
|
||||
current.set("text", "未知促销文字")
|
||||
elif kind == "unknown_bounds":
|
||||
frame.set("bounds", "[396,574][1053,647]")
|
||||
elif kind == "unknown_child":
|
||||
row.append(ElementTree.Element("node", dict(current.attrib) | {"text": "未知节点"}))
|
||||
elif kind == "parent_drift":
|
||||
frame.remove(row)
|
||||
parents[frame].append(row)
|
||||
elif kind == "summary_parent_drift":
|
||||
summary = next(
|
||||
node for node in root.iter("node")
|
||||
if node.get("bounds") == "[396,731][1053,793]"
|
||||
and node.get("class") == "android.widget.TextView"
|
||||
)
|
||||
parents[summary].remove(summary)
|
||||
parents[parents[summary]].append(summary)
|
||||
else:
|
||||
raise AssertionError(kind)
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
for kind in (
|
||||
"cross_variant",
|
||||
"duplicate_frame",
|
||||
"duplicate_current",
|
||||
"unknown_text",
|
||||
"unknown_bounds",
|
||||
"unknown_child",
|
||||
"parent_drift",
|
||||
"summary_parent_drift",
|
||||
):
|
||||
with self.subTest(kind=kind), self.assertRaises(SkuSelectionError):
|
||||
_classify_panel(_parse_nodes(mutate(kind)))
|
||||
|
||||
def test_color_click_rejects_dual_current_only_cross_variant_in_both_directions(self) -> None:
|
||||
cases = (
|
||||
(_CURRENT_ONLY_EMPTY_FIXTURE, _COLOR_FIXTURE),
|
||||
(_EMPTY_FIXTURE, _CURRENT_ONLY_COLOR_FIXTURE),
|
||||
)
|
||||
for empty_fixture, color_fixture in cases:
|
||||
with self.subTest(empty=empty_fixture.name, color=color_fixture.name):
|
||||
device = _RawDevice()
|
||||
device.panel_hierarchy = empty_fixture.read_text(encoding="utf-8")
|
||||
device.color_hierarchy = color_fixture.read_text(encoding="utf-8")
|
||||
flow = _flow(device)
|
||||
flow.open_sku_panel(_TARGET_URL)
|
||||
|
||||
with self.assertRaises(SkuSelectionError) as raised:
|
||||
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||
|
||||
self.assertNotIn("受控显示动作尚未取证", str(raised.exception))
|
||||
self.assertEqual(_tap_centers(device), [(865, 2218), (528, 1387)])
|
||||
self.assertEqual(_actions(device, "pressKey"), [])
|
||||
|
||||
def test_full_verified_entry_structure_taps_exact_text_child_once(self) -> None:
|
||||
device = _RawDevice()
|
||||
_flow(device).open_sku_panel(_TARGET_URL)
|
||||
@@ -708,6 +932,8 @@ class SkuSelectionFlowTests(unittest.TestCase):
|
||||
for fixture in (
|
||||
_EMPTY_FIXTURE,
|
||||
_COLOR_FIXTURE,
|
||||
_CURRENT_ONLY_EMPTY_FIXTURE,
|
||||
_CURRENT_ONLY_COLOR_FIXTURE,
|
||||
_S_FIXTURE,
|
||||
_M_FIXTURE,
|
||||
_ENTRY_FIXTURE,
|
||||
|
||||
Reference in New Issue
Block a user