fix: 蛇形遍历颜色并逐色采价 (#37)

This commit is contained in:
chengma
2026-08-08 09:25:40 +08:00
parent e08fd89c91
commit 9562da9aad
7 changed files with 668 additions and 202 deletions
+209 -2
View File
@@ -92,6 +92,127 @@ class PanelDoesNotOpenDevice(FakeCollectDevice):
self.clicks.append((x, y))
class SnakeColorDevice(FakeCollectDevice):
"""模拟两行横向颜色网格和滚动后才出现的尺码区域。"""
prices = {
"A色": 1000,
"B色": 1000,
"C色": 1200,
"D色": 1300,
"E色": 1400,
"F色": 1500,
}
def __init__(self, home_xml):
super().__init__(home_xml, "")
self.page = 1
self.selected_color = "A色"
self.sizes_visible = False
self.clicked_colors = []
def dump_hierarchy(self):
if not self.panel_open:
return self.home_xml
return self._spec_xml()
def click(self, x, y):
self.clicks.append((x, y))
if not self.panel_open:
self.panel_open = True
return
for name, bounds in self._current_color_bounds().items():
left, top, right, bottom = bounds
if left <= x <= right and top <= y <= bottom:
self.selected_color = name
self.clicked_colors.append(name)
return
def swipe(self, x1, y1, x2, y2, duration=0.35):
self.swipes.append(((x1, y1, x2, y2), {"duration": duration}))
self.swipe_panel_states.append(self.panel_open)
if abs(x2 - x1) > abs(y2 - y1):
self.page = 1 if x1 > x2 else 0
else:
self.sizes_visible = True
def _current_color_bounds(self):
if self.page == 0:
return {
"A色": (36, 740, 340, 850),
"B色": (370, 740, 674, 850),
"C色": (1044, 740, 1080, 850),
"D色": (36, 900, 340, 1010),
"E色": (370, 900, 674, 1010),
"F色": (1044, 900, 1080, 1010),
}
return {
"A色": (0, 740, 20, 850),
"B色": (36, 740, 340, 850),
"C色": (370, 740, 674, 850),
"D色": (0, 900, 20, 1010),
"E色": (36, 900, 340, 1010),
"F色": (370, 900, 674, 1010),
}
def _spec_xml(self):
color_nodes = []
for name, bounds in self._current_color_bounds().items():
left, top, right, bottom = bounds
selected = "true" if name == self.selected_color else "false"
color_nodes.append(
f'<node class="android.view.ViewGroup" content-desc="{name}" '
f'clickable="true" selected="{selected}" '
f'bounds="[{left},{top}][{right},{bottom}]" '
'visible-to-user="true" enabled="true" />'
)
size_nodes = ""
if self.sizes_visible:
size_nodes = """
<node class="android.widget.TextView" text="尺码"
bounds="[36,1120][130,1180]" visible-to-user="true"
enabled="true" />
<node class="android.view.ViewGroup" content-desc="M"
clickable="true" bounds="[36,1210][340,1300]"
visible-to-user="true" enabled="true" />
<node class="android.view.ViewGroup" content-desc="L"
clickable="true" bounds="[370,1210][674,1300]"
visible-to-user="true" enabled="true" />
"""
price = self.prices[self.selected_color] / 100
return f"""<hierarchy>
<node class="android.widget.FrameLayout" bounds="[0,0][1080,2000]"
visible-to-user="true" enabled="true">
<node class="android.widget.TextView" text="¥{price:.2f}"
bounds="[390,300][650,380]" visible-to-user="true"
enabled="true" />
<node class="androidx.recyclerview.widget.RecyclerView"
scrollable="true" bounds="[0,600][1080,1700]"
visible-to-user="true" enabled="true">
<node class="android.widget.TextView" text="颜色分类"
bounds="[36,650][200,710]" visible-to-user="true"
enabled="true" />
<node class="androidx.recyclerview.widget.RecyclerView"
scrollable="true" bounds="[36,720][1044,1060]"
visible-to-user="true" enabled="true">
{''.join(color_nodes)}
</node>
{size_nodes}
</node>
</node>
</hierarchy>"""
class IgnoredColorClickDevice(SnakeColorDevice):
"""页面暴露选中状态,但模拟一次没有生效的颜色点击。"""
def click(self, x, y):
if not self.panel_open:
super().click(x, y)
return
self.clicks.append((x, y))
def keep_only_one_sku(xml_data: str) -> str:
"""从脱敏固件中删除蓝色和 L,只保留一个组合。"""
@@ -202,7 +323,7 @@ class PddCollectParserTest(unittest.TestCase):
self.assertEqual(data["skus"][0]["price_cent"], 1000)
self.assertEqual(
data["skus"][0]["price_observed_at"],
{"color": "红色", "size": "M"},
{"color": "红色"},
)
self.assertEqual(data["source"]["device_address"], "USB-001")
self.assertIsNone(data["purchase"])
@@ -248,11 +369,97 @@ class PddCollectParserTest(unittest.TestCase):
item for item in data["skus"] if item["options"]["color"] == "红色"
]
self.assertEqual(
{item["price_observed_at"]["size"] for item in red_skus}, {"M"}
{item["price_observed_at"]["color"] for item in red_skus},
{"红色"},
)
self.assertTrue(
all(
set(item["price_observed_at"]) == {"color"}
for item in red_skus
)
)
# 1 次打开规格面板,另有红色点 1 次;尺码只读不点击。
self.assertEqual(len(device.clicks), 2)
def test_colors_are_sampled_in_snake_order_before_sizes_are_read(self):
device = SnakeColorDevice(self.home_xml)
service = PddCollectService(
PddDeviceService(lambda _serial: device),
"USB-001",
"client-001",
sleeper=lambda _seconds: None,
)
data = service.collect(
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
).to_pdd_data()
self.assertEqual(
device.clicked_colors,
["A色", "B色", "C色", "F色", "E色", "D色"],
)
self.assertEqual(
[item["text"] for item in data["dimensions"][0]["values"]],
["A色", "B色", "C色", "D色", "E色", "F色"],
)
self.assertEqual(
[item["text"] for item in data["dimensions"][1]["values"]],
["M", "L"],
)
color_prices = {
item["options"]["color"]: item["price_cent"]
for item in data["skus"]
if item["options"]["size"] == "M"
}
self.assertEqual(color_prices["A色"], 1000)
self.assertEqual(color_prices["B色"], 1000)
self.assertEqual(color_prices["F色"], 1500)
self.assertTrue(all(
item["price_observed_at"] == {"color": item["options"]["color"]}
for item in data["skus"]
))
def test_visible_click_is_fallback_when_page_exposes_no_selection_state(self):
xml_data = keep_only_one_sku(self.spec_xml)
root = ET.fromstring(xml_data)
for parent in root.iter():
for child in list(parent):
if (child.get("text") or "").startswith("已选"):
parent.remove(child)
xml_without_selection = ET.tostring(root, encoding="unicode")
device = FakeCollectDevice(self.home_xml, xml_without_selection)
service = PddCollectService(
PddDeviceService(lambda _serial: device),
"USB-001",
"client-001",
sleeper=lambda _seconds: None,
max_spec_swipes=0,
)
data = service.collect(
FakeTask("https://mobile.yangkeduo.com/goods.html?goods_id=123")
).to_pdd_data()
self.assertEqual(data["skus"][0]["price_cent"], 1000)
self.assertEqual(
data["skus"][0]["price_observed_at"], {"color": "红色"}
)
def test_visible_text_does_not_confirm_an_ignored_click(self):
device = IgnoredColorClickDevice(self.home_xml)
device.panel_open = True
device.page = 0
service = PddCollectService(
PddDeviceService(lambda _serial: device),
"USB-001",
"client-001",
sleeper=lambda _seconds: None,
)
sample = service._click_and_sample_color(device, "B色")
self.assertIsNone(sample.price_cent)
def test_missing_goods_id_is_structured_error(self):
service = PddCollectService(
PddDeviceService(lambda _serial: object()),