from __future__ import annotations
import base64
from contextlib import redirect_stderr
from io import BytesIO
import importlib.util
import io
import json
from pathlib import Path
from tempfile import TemporaryDirectory
import unittest
from unittest.mock import patch
from xml.etree import ElementTree
from PIL import Image
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
from cmbuyer_client.pdd.sku_reveal_spike import (
SkuRevealSpikeCapturer,
SkuRevealSpikeError,
_require_safe_reveal_path,
)
from cmbuyer_client.pdd.sku_selection import SkuSelectionError, _parse_nodes
_FIXTURES = Path(__file__).with_name("fixtures")
_PRODUCT = (_FIXTURES / "product_entry_8_17_0.xml").read_text(encoding="utf-8")
_EMPTY = (_FIXTURES / "sku_panel_empty_8_17_0.xml").read_text(encoding="utf-8")
_COLOR_ONLY = (_FIXTURES / "sku_panel_color_selected_size_hidden_8_17_0.xml").read_text(encoding="utf-8")
_M_SELECTED = (_FIXTURES / "sku_panel_size_m_restored_8_17_0.xml").read_text(encoding="utf-8")
def _candidate_unselected() -> str:
root = ElementTree.fromstring(_M_SELECTED)
parents = {child: parent for parent in root.iter() for child in parent}
for node in root.iter("node"):
if node.get("text") == "已选: 黑色 CHA (纯棉) M(建议100-115)":
node.set("text", "请选择: 尺码")
if node.get("text") in {"S(建议80-100)", "M(建议100-115)"}:
node.set("selected", "false")
wrapper = parents[node]
for child in list(wrapper):
if child.get("class") == "android.view.View":
wrapper.remove(child)
action_root = next(
node
for node in root.iter("node")
if node.get("class") == "android.view.ViewGroup"
and node.get("clickable") == "true"
and node.get("bounds") == "[0,366][1080,2328]"
)
button_frame = ElementTree.SubElement(
action_root,
"node",
{
"class": "android.widget.FrameLayout",
"package": "com.xunmeng.pinduoduo",
"text": "",
"content-desc": "",
"clickable": "true",
"enabled": "true",
"visible-to-user": "true",
"selected": "false",
"scrollable": "false",
"bounds": "[0,2181][1080,2328]",
},
)
button_content = ElementTree.SubElement(
button_frame,
"node",
{
"class": "android.widget.LinearLayout",
"package": "com.xunmeng.pinduoduo",
"text": "",
"content-desc": "",
"clickable": "false",
"enabled": "true",
"visible-to-user": "true",
"selected": "false",
"scrollable": "false",
"bounds": "[273,2181][807,2328]",
},
)
ElementTree.SubElement(
button_content,
"node",
{
"class": "android.widget.TextView",
"package": "com.xunmeng.pinduoduo",
"text": "选择尺码后,提交订单",
"content-desc": "",
"clickable": "false",
"enabled": "true",
"visible-to-user": "true",
"selected": "false",
"scrollable": "false",
"bounds": "[285,2225][795,2284]",
},
)
return ElementTree.tostring(root, encoding="unicode")
def _candidate_with_color_unselected() -> str:
root = ElementTree.fromstring(_candidate_unselected())
target = next(
node
for node in root.iter("node")
if node.get("class") == "android.view.ViewGroup"
and node.get("content-desc") == "黑色 CHA (纯棉)"
and node.get("bounds") == "[372,1000][684,1024]"
)
for node in target.iter("node"):
node.set("selected", "false")
return ElementTree.tostring(root, encoding="unicode")
def _candidate_with_submit_risk(kind: str) -> str:
root = ElementTree.fromstring(_candidate_unselected())
parents = {child: parent for parent in root.iter() for child in parent}
submit = next(
node
for node in root.iter("node")
if "提交订单" in node.get("text", "")
and node.get("class") == "android.widget.TextView"
)
parent = parents[submit]
if kind == "missing":
parent.remove(submit)
elif kind == "duplicate":
parent.append(ElementTree.fromstring(ElementTree.tostring(submit, encoding="unicode")))
elif kind == "moved_up":
submit.set("bounds", "[285,1900][795,1959]")
else:
raise AssertionError(kind)
return ElementTree.tostring(root, encoding="unicode")
def _with_clickable_overlay(package: str, class_name: str, bounds: str) -> str:
root = ElementTree.fromstring(_COLOR_ONLY)
container = next(root.iter("node"))
ElementTree.SubElement(
container,
"node",
{
"package": package,
"class": class_name,
"text": "",
"content-desc": "",
"clickable": "true",
"enabled": "true",
"visible-to-user": "true",
"selected": "false",
"scrollable": "false",
"bounds": bounds,
},
)
return ElementTree.tostring(root, encoding="unicode")
def _png() -> str:
image = Image.new("RGB", (1080, 2376), "white")
raw = BytesIO()
image.save(raw, format="PNG")
return base64.b64encode(raw.getvalue()).decode("ascii")
class _FakeDevice:
def __init__(self) -> None:
self.hierarchy = ""
self.after_hierarchy = _candidate_unselected()
self.calls: list[tuple[object, ...]] = []
self.swipe_error = False
def app_info(self, package_name: str) -> dict[str, str]:
self.calls.append(("app_info", package_name))
return {"versionName": "8.17.0"}
def app_current(self) -> dict[str, str]:
self.calls.append(("app_current",))
return {"package": "com.xunmeng.pinduoduo"}
def window_size(self) -> tuple[int, int]:
self.calls.append(("window_size",))
return 1080, 2376
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
self.calls.append(("jsonrpc", method, params, timeout))
if method == "dumpWindowHierarchy":
return self.hierarchy
if method == "takeScreenshot":
return _png()
if method == "click":
if params == [865, 2218]:
self.hierarchy = _EMPTY
return ""
if params == [528, 1387]:
self.hierarchy = _COLOR_ONLY
return ""
raise AssertionError(params)
if method == "swipe":
self.hierarchy = self.after_hierarchy
if self.swipe_error:
raise TimeoutError("unknown outcome")
return ""
raise AssertionError(method)
class _FakeAdb:
def __init__(self, device: _FakeDevice) -> None:
self.device = device
self.calls: list[tuple[object, ...]] = []
def inspect(self, serial: str) -> DeviceInspection:
self.calls.append(("inspect", serial))
return DeviceInspection(AdbDevice(serial=serial, state="device"), "PKG110", "16")
def start_pdd_view_intent(self, serial: str, goods_id: str) -> object:
self.calls.append(("intent", serial, goods_id))
self.device.hierarchy = _PRODUCT
return object()
def _swipes(device: _FakeDevice) -> list[tuple[object, ...]]:
return [call for call in device.calls if call[:2] == ("jsonrpc", "swipe")]
class SkuRevealSpikeTests(unittest.TestCase):
def _capturer(self, device: _FakeDevice) -> SkuRevealSpikeCapturer:
now = [0.0]
return SkuRevealSpikeCapturer(
_FakeAdb(device),
lambda serial: device,
30,
monotonic_clock=lambda: now[0],
sleep_function=lambda seconds: now.__setitem__(0, now[0] + seconds),
)
def _assert_after_rejected_once(self, after_hierarchy: str, *, ambiguous: bool = False) -> None:
device = _FakeDevice()
device.after_hierarchy = after_hierarchy
device.swipe_error = ambiguous
with TemporaryDirectory() as directory:
target = Path(directory) / "evidence"
with self.assertRaises(SkuRevealSpikeError):
self._capturer(device).capture(
"192.168.0.173:5555",
"937122477375",
target,
)
self.assertEqual(len(_swipes(device)), 1)
self.assertFalse(target.exists())
self.assertEqual(list(Path(directory).glob(".*.staging-*")), [])
def test_success_publishes_before_after_and_exactly_one_reveal(self) -> None:
device = _FakeDevice()
with TemporaryDirectory() as directory:
target = Path(directory) / "evidence"
result = self._capturer(device).capture("192.168.0.173:5555", "937122477375", target)
self.assertEqual(result.output_directory, target)
self.assertEqual(len(_swipes(device)), 1)
self.assertFalse(any(call[1] == "pressKey" for call in device.calls if call[0] == "jsonrpc"))
for relative in (
"before/screenshot.png",
"before/hierarchy.xml",
"after/screenshot.png",
"after/hierarchy.xml",
"manifest.json",
):
self.assertTrue((target / relative).is_file(), relative)
manifest_text = result.manifest_path.read_text(encoding="utf-8")
manifest = json.loads(manifest_text)
self.assertEqual(manifest["reveal_attempts"], 1)
self.assertEqual(manifest["rpc_outcome"], "completed")
self.assertNotIn("192.168.0.173:5555", manifest_text)
self.assertNotIn("gesture", manifest)
self.assertNotIn("coordinates", manifest)
self.assertNotIn("start", manifest)
self.assertNotIn("end", manifest)
def test_ambiguous_rpc_is_read_only_reconciled_without_retry(self) -> None:
device = _FakeDevice()
device.swipe_error = True
with TemporaryDirectory() as directory:
target = Path(directory) / "evidence"
result = self._capturer(device).capture("192.168.0.173:5555", "937122477375", target)
self.assertEqual(len(_swipes(device)), 1)
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
self.assertEqual(manifest["rpc_outcome"], "ambiguous_reconciled")
def test_selected_m_or_precondition_drift_never_publishes_or_retries(self) -> None:
for after in (
_M_SELECTED,
_candidate_unselected().replace("请选择: 尺码", "已选: 尺码"),
):
with self.subTest():
device = _FakeDevice()
device.after_hierarchy = after
with TemporaryDirectory() as directory:
target = Path(directory) / "evidence"
with self.assertRaises(SkuRevealSpikeError):
self._capturer(device).capture("192.168.0.173:5555", "937122477375", target)
self.assertEqual(len(_swipes(device)), 1)
self.assertFalse(target.exists())
self.assertEqual(list(Path(directory).glob(".*.staging-*")), [])
device = _FakeDevice()
device.hierarchy = ""
invalid_color = _COLOR_ONLY.replace("请选择: 尺码", "请选择: 颜色分类 尺码")
original_call = device.jsonrpc_call
def drift(method: str, params: object = None, timeout: float = 10) -> str:
value = original_call(method, params, timeout)
if method == "click" and params == [528, 1387]:
device.hierarchy = invalid_color
return value
device.jsonrpc_call = drift # type: ignore[method-assign]
with TemporaryDirectory() as directory:
with self.assertRaises((SkuSelectionError, SkuRevealSpikeError)):
self._capturer(device).capture(
"192.168.0.173:5555",
"937122477375",
Path(directory) / "evidence",
)
self.assertEqual(_swipes(device), [])
def test_hidden_sizes_after_swipe_are_not_published_or_retried(self) -> None:
self._assert_after_rejected_once(_COLOR_ONLY)
def test_post_reveal_color_selected_drift_is_not_published_or_retried(self) -> None:
self._assert_after_rejected_once(_candidate_with_color_unselected())
def test_submit_hard_reject_zone_risk_is_not_published_or_retried(self) -> None:
for kind in ("missing", "duplicate", "moved_up"):
with self.subTest(kind=kind):
self._assert_after_rejected_once(_candidate_with_submit_risk(kind))
def test_ambiguous_rpc_with_unchanged_page_is_not_published_or_retried(self) -> None:
self._assert_after_rejected_once(_COLOR_ONLY, ambiguous=True)
def test_invalid_goods_and_existing_target_are_zero_action(self) -> None:
device = _FakeDevice()
with TemporaryDirectory() as directory:
target = Path(directory) / "existing"
target.mkdir()
with self.assertRaises(SkuRevealSpikeError):
self._capturer(device).capture("wifi", "1", Path(directory) / "new")
with self.assertRaises(SkuRevealSpikeError):
self._capturer(device).capture("wifi", "937122477375", target)
self.assertEqual(device.calls, [])
def test_before_screenshot_drift_is_rechecked_before_zero_swipe(self) -> None:
device = _FakeDevice()
original_call = device.jsonrpc_call
def drift(method: str, params: object = None, timeout: float = 10) -> str:
value = original_call(method, params, timeout)
if method == "takeScreenshot":
device.hierarchy = _COLOR_ONLY.replace("请选择: 尺码", "请选择: 颜色分类 尺码")
return value
device.jsonrpc_call = drift # type: ignore[method-assign]
with TemporaryDirectory() as directory:
target = Path(directory) / "evidence"
with self.assertRaises(SkuSelectionError):
self._capturer(device).capture(
"192.168.0.173:5555",
"937122477375",
target,
)
self.assertFalse(target.exists())
self.assertEqual(_swipes(device), [])
def test_complete_reveal_segment_rejects_narrow_impostor_and_invalid_bounds(self) -> None:
for hierarchy in (
_with_clickable_overlay(
"com.xunmeng.pinduoduo",
"android.view.ViewGroup",
"[350,1450][370,1460]",
),
_with_clickable_overlay(
"com.android.systemui",
"android.view.ViewGroup",
"[0,366][1080,2328]",
),
_with_clickable_overlay(
"com.xunmeng.pinduoduo",
"android.view.ViewGroup",
"not-a-bound",
),
):
with self.subTest(), self.assertRaises(SkuRevealSpikeError):
_require_safe_reveal_path(_parse_nodes(hierarchy))
class SkuRevealSpikeCliTests(unittest.TestCase):
def test_cli_has_no_gesture_or_task_specification_parameters(self) -> None:
script = _load_reveal_script()
arguments = script.parse_arguments(
[
"--serial", "device-1",
"--goods-id", "937122477375",
"--output-dir", "evidence",
]
)
self.assertEqual(
set(vars(arguments)),
{"serial", "goods_id", "output_dir", "timeout", "adb"},
)
script.validate_arguments(arguments)
for field, value in (
("serial", ""),
("goods_id", "1"),
("timeout", 0),
("timeout", float("inf")),
):
with self.subTest(field=field), self.assertRaises(ValueError):
script.validate_arguments(
type("Arguments", (), vars(arguments) | {field: value})()
)
def test_cli_failure_is_redacted(self) -> None:
script = _load_reveal_script()
secret = "SERIAL=192.168.0.173:5555 private"
class FailingCapturer:
def __init__(self, *args: object, **kwargs: object) -> None:
return None
def capture(self, *args: object, **kwargs: object) -> object:
raise SkuRevealSpikeError(secret)
stderr = io.StringIO()
with patch.object(script, "SkuRevealSpikeCapturer", FailingCapturer), redirect_stderr(stderr):
status = script.main(
[
"--serial", "192.168.0.173:5555",
"--goods-id", "937122477375",
"--output-dir", "evidence",
]
)
output = stderr.getvalue()
self.assertEqual(status, 1)
self.assertNotIn("Traceback", output)
self.assertNotIn("192.168.0.173:5555", output)
self.assertNotIn("private", output)
def _load_reveal_script() -> object:
path = Path(__file__).resolve().parents[2] / "scripts" / "capture_sku_reveal_spike.py"
specification = importlib.util.spec_from_file_location("capture_sku_reveal_spike_test", path)
if specification is None or specification.loader is None:
raise RuntimeError("无法加载 T-103 reveal 取证脚本。")
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)
return module
if __name__ == "__main__":
unittest.main()