fix: 蛇形遍历颜色并逐色采价 (#37)
This commit is contained in:
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user