feat(client): capture T-105 quantity states
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
"""采集 T-105 人工准备的数量 1/2 两态证据;不执行页面操作。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from math import isfinite
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
|
||||
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
|
||||
from cmbuyer_client.pdd.quantity_gate2_spike import (
|
||||
DECLARED_QUANTITIES,
|
||||
EXPECTED_GOODS_ID,
|
||||
QuantityGate2EvidenceCapturer,
|
||||
QuantityGate2EvidenceError,
|
||||
)
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="采集 T-105 人工准备的数量两态本机证据。")
|
||||
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||
parser.add_argument("--goods-id", required=True, help="T-105 已批准的目标商品标识。")
|
||||
parser.add_argument(
|
||||
"--state",
|
||||
required=True,
|
||||
choices=sorted(DECLARED_QUANTITIES),
|
||||
help="人工声明状态:initial=数量1,target=数量2。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--declared-quantity",
|
||||
required=True,
|
||||
type=int,
|
||||
help="人工看到的数量;必须与状态一致。",
|
||||
)
|
||||
parser.add_argument("--output-dir", required=True, type=Path, help="全新本机证据目录;不得覆盖。")
|
||||
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 与只读设备 RPC 超时(秒)。")
|
||||
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def validate_arguments(arguments: argparse.Namespace) -> None:
|
||||
if (
|
||||
type(arguments.serial) is not str
|
||||
or not arguments.serial.strip()
|
||||
or arguments.serial != arguments.serial.strip()
|
||||
):
|
||||
raise ValueError("必须显式提供非空 --serial。")
|
||||
if type(arguments.goods_id) is not str or arguments.goods_id != EXPECTED_GOODS_ID:
|
||||
raise ValueError("--goods-id 不是 T-105 已批准目标。")
|
||||
if type(arguments.state) is not str or arguments.state not in DECLARED_QUANTITIES:
|
||||
raise ValueError("--state 必须是批准的人工声明状态。")
|
||||
if type(arguments.declared_quantity) is not int or (
|
||||
arguments.declared_quantity != DECLARED_QUANTITIES[arguments.state]
|
||||
):
|
||||
raise ValueError("--declared-quantity 必须与人工声明状态一致。")
|
||||
if not isinstance(arguments.output_dir, Path) or not arguments.output_dir.name:
|
||||
raise ValueError("--output-dir 必须是明确的全新目录。")
|
||||
if (
|
||||
not isinstance(arguments.timeout, (int, float))
|
||||
or isinstance(arguments.timeout, bool)
|
||||
or arguments.timeout <= 0
|
||||
or not isfinite(arguments.timeout)
|
||||
):
|
||||
raise ValueError("--timeout 必须是大于 0 的有限数值。")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
validate_arguments(arguments)
|
||||
except ValueError as error:
|
||||
print(f"失败:{error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
import adbutils
|
||||
import uiautomator2 as u2
|
||||
except ImportError:
|
||||
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
capturer = QuantityGate2EvidenceCapturer(
|
||||
AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout),
|
||||
NoReconnectUiautomatorConnector(
|
||||
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
|
||||
u2.connect,
|
||||
),
|
||||
timeout_seconds=arguments.timeout,
|
||||
)
|
||||
try:
|
||||
capturer.capture(
|
||||
arguments.serial,
|
||||
arguments.goods_id,
|
||||
arguments.state,
|
||||
arguments.declared_quantity,
|
||||
arguments.output_dir,
|
||||
)
|
||||
except (DeviceConnectionError, QuantityGate2EvidenceError):
|
||||
# 第三方异常可能含 serial、Activity、本机路径或页面正文,CLI 只输出固定摘要。
|
||||
print("数量两态只读取证失败:已停止,未发布本机证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
except OSError:
|
||||
print("数量两态只读取证失败:无法发布本机证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("数量两态只读取证完成。")
|
||||
print("人工复核:请在指定目录检查截图、XML、应用摘要和 manifest。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,303 @@
|
||||
"""T-105 数量两态的纯只读取证;人工确认前不识别或操作数量控件。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from math import isfinite
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from adbutils.errors import AdbTimeout
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from uiautomator2.exceptions import HTTPTimeoutError
|
||||
|
||||
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
|
||||
from ..device.baseline import (
|
||||
HIERARCHY_PARAMS,
|
||||
PDD_PACKAGE,
|
||||
SCREENSHOT_PARAMS,
|
||||
_save_base64_screenshot,
|
||||
_sha256_file,
|
||||
_validate_hierarchy,
|
||||
)
|
||||
from .product_open import EXPECTED_PDD_VERSION
|
||||
from .product_url import parse_product_url
|
||||
|
||||
|
||||
EXPECTED_GOODS_ID = "937122477375"
|
||||
EXPECTED_DEVICE_MODEL = "PKG110"
|
||||
EXPECTED_ANDROID_VERSION = "16"
|
||||
EXPECTED_SCREEN_SIZE = (1080, 2376)
|
||||
TARGET_SELECTION = {"color": "黑色CHA(纯棉)", "size": "M(建议100-115)"}
|
||||
DECLARED_QUANTITIES = {"initial": 1, "target": 2}
|
||||
|
||||
|
||||
class QuantityGate2EvidenceError(RuntimeError):
|
||||
"""T-105 两态证据未形成完整原子产物。"""
|
||||
|
||||
|
||||
class QuantityGate2EvidenceTimeoutError(QuantityGate2EvidenceError):
|
||||
"""只读取证设备调用超时。"""
|
||||
|
||||
|
||||
class QuantityGate2ReadDevice(Protocol):
|
||||
"""阶段一唯一设备边界;故意不暴露任何页面动作。"""
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]: ...
|
||||
|
||||
def app_current(self) -> dict[str, Any]: ...
|
||||
|
||||
def window_size(self) -> tuple[int, int]: ...
|
||||
|
||||
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuantityGate2EvidenceResult:
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
screenshot_path: Path
|
||||
hierarchy_path: Path
|
||||
app_path: Path
|
||||
|
||||
|
||||
class QuantityGate2EvidenceCapturer:
|
||||
"""记录人工准备的数量状态,不从页面推断声明是否正确。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adb_client: AdbClient,
|
||||
connector: Callable[[str], QuantityGate2ReadDevice],
|
||||
timeout_seconds: float,
|
||||
) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._adb_client = adb_client
|
||||
self._connector = connector
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._started = False
|
||||
|
||||
def capture(
|
||||
self,
|
||||
serial: str,
|
||||
goods_id: str,
|
||||
human_declared_state: str,
|
||||
human_declared_quantity: int,
|
||||
output_directory: Path,
|
||||
) -> QuantityGate2EvidenceResult:
|
||||
if self._started:
|
||||
raise QuantityGate2EvidenceError("同一取证器不可重复调用。")
|
||||
self._started = True
|
||||
_validate_inputs(serial, goods_id, human_declared_state, human_declared_quantity)
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
|
||||
staging: Path | None = None
|
||||
try:
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
_require_expected_device(inspection)
|
||||
device = self._connector(serial)
|
||||
initial_app = _require_read_precondition(device)
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
||||
staging.mkdir()
|
||||
|
||||
screenshot_path = staging / "screenshot.png"
|
||||
screenshot_payload = _read_rpc(
|
||||
device,
|
||||
"takeScreenshot",
|
||||
SCREENSHOT_PARAMS,
|
||||
self._timeout_seconds,
|
||||
)
|
||||
if not isinstance(screenshot_payload, str):
|
||||
raise QuantityGate2EvidenceError("数量状态截图无效,未发布证据。")
|
||||
_save_base64_screenshot(screenshot_payload, screenshot_path)
|
||||
_require_screenshot_size(screenshot_path)
|
||||
|
||||
hierarchy = _read_rpc(
|
||||
device,
|
||||
"dumpWindowHierarchy",
|
||||
HIERARCHY_PARAMS,
|
||||
self._timeout_seconds,
|
||||
)
|
||||
_validate_hierarchy(hierarchy)
|
||||
hierarchy_path = staging / "hierarchy.xml"
|
||||
hierarchy_path.write_text(hierarchy, encoding="utf-8")
|
||||
|
||||
final_app = _require_read_precondition(device)
|
||||
if final_app != initial_app:
|
||||
raise QuantityGate2EvidenceError("数量状态取证期间前台页面漂移,未发布证据。")
|
||||
app_path = staging / "app.json"
|
||||
app_path.write_text(
|
||||
json.dumps(final_app, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
manifest_path = staging / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
_manifest(
|
||||
inspection,
|
||||
serial,
|
||||
goods_id,
|
||||
human_declared_state,
|
||||
human_declared_quantity,
|
||||
screenshot_path,
|
||||
hierarchy_path,
|
||||
app_path,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.rename(staging, target)
|
||||
staging = None
|
||||
except (DeviceConnectionError, QuantityGate2EvidenceError):
|
||||
_clean_staging(staging)
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
_clean_staging(staging)
|
||||
raise QuantityGate2EvidenceTimeoutError("数量两态只读取证超时,未发布证据。") from error
|
||||
except (OSError, UnidentifiedImageError, ValueError) as error:
|
||||
_clean_staging(staging)
|
||||
raise QuantityGate2EvidenceError("数量两态证据无法原子发布,未发布证据。") from error
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
raise QuantityGate2EvidenceError("数量两态只读取证未完成,未发布证据。") from error
|
||||
|
||||
return QuantityGate2EvidenceResult(
|
||||
output_directory=target,
|
||||
manifest_path=target / "manifest.json",
|
||||
screenshot_path=target / "screenshot.png",
|
||||
hierarchy_path=target / "hierarchy.xml",
|
||||
app_path=target / "app.json",
|
||||
)
|
||||
|
||||
|
||||
def _is_positive_finite(value: object) -> bool:
|
||||
return (
|
||||
isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and value > 0
|
||||
and isfinite(value)
|
||||
)
|
||||
|
||||
|
||||
def _validate_inputs(serial: object, goods_id: object, state: object, quantity: object) -> None:
|
||||
if type(serial) is not str or not serial.strip() or serial != serial.strip():
|
||||
raise QuantityGate2EvidenceError("必须显式提供非空设备通道。")
|
||||
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
|
||||
raise QuantityGate2EvidenceError("商品不是 T-105 已批准取证目标。")
|
||||
parse_product_url(f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}")
|
||||
if type(state) is not str or state not in DECLARED_QUANTITIES:
|
||||
raise QuantityGate2EvidenceError("人工声明状态无效。")
|
||||
if type(quantity) is not int or quantity != DECLARED_QUANTITIES[state]:
|
||||
raise QuantityGate2EvidenceError("人工声明数量与批准状态不一致。")
|
||||
|
||||
|
||||
def _validate_new_target(target: Path) -> None:
|
||||
if target.exists() or not target.name:
|
||||
raise QuantityGate2EvidenceError("输出目录必须是不存在的明确新目录。")
|
||||
|
||||
|
||||
def _require_expected_device(inspection: DeviceInspection) -> None:
|
||||
if inspection.model != EXPECTED_DEVICE_MODEL or inspection.android_version != EXPECTED_ANDROID_VERSION:
|
||||
raise QuantityGate2EvidenceError("设备不是已批准取证组合。")
|
||||
|
||||
|
||||
def _require_read_precondition(device: QuantityGate2ReadDevice) -> dict[str, str]:
|
||||
info = device.app_info(PDD_PACKAGE)
|
||||
version = (info.get("versionName") or info.get("version_name")) if isinstance(info, dict) else None
|
||||
if version != EXPECTED_PDD_VERSION:
|
||||
raise QuantityGate2EvidenceError("拼多多版本不是已批准取证版本。")
|
||||
current = device.app_current()
|
||||
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
|
||||
raise QuantityGate2EvidenceError("拼多多不在前台。")
|
||||
activity = current.get("activity")
|
||||
if not isinstance(activity, str) or not activity.strip():
|
||||
raise QuantityGate2EvidenceError("前台应用摘要不完整。")
|
||||
if device.window_size() != EXPECTED_SCREEN_SIZE:
|
||||
raise QuantityGate2EvidenceError("屏幕坐标空间不是已批准尺寸。")
|
||||
return {"package": PDD_PACKAGE, "activity": activity, "pdd_version": EXPECTED_PDD_VERSION}
|
||||
|
||||
|
||||
def _read_rpc(
|
||||
device: QuantityGate2ReadDevice,
|
||||
method: str,
|
||||
params: object,
|
||||
timeout_seconds: float,
|
||||
) -> object:
|
||||
return device.jsonrpc_call(method, params, timeout=timeout_seconds)
|
||||
|
||||
|
||||
def _require_screenshot_size(path: Path) -> None:
|
||||
with Image.open(path) as image:
|
||||
image.load()
|
||||
if image.size != EXPECTED_SCREEN_SIZE or image.format != "PNG":
|
||||
raise QuantityGate2EvidenceError("数量状态截图格式或尺寸无效。")
|
||||
|
||||
|
||||
def _clean_staging(staging: Path | None) -> None:
|
||||
if staging is not None and staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
|
||||
|
||||
def _manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
goods_id: str,
|
||||
state: str,
|
||||
quantity: int,
|
||||
screenshot_path: Path,
|
||||
hierarchy_path: Path,
|
||||
app_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"operation": "t105-quantity-gate2-readonly-evidence",
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"product": {
|
||||
"goods_id": goods_id,
|
||||
"canonical_url": f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}",
|
||||
},
|
||||
"human_declared_state": state,
|
||||
"human_declared_quantity": quantity,
|
||||
"human_declared_selection": TARGET_SELECTION,
|
||||
"review_status": "human_review_required",
|
||||
"channel": "wifi" if ":" in serial else "usb",
|
||||
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||
"device": {
|
||||
"model": inspection.model,
|
||||
"android_version": inspection.android_version,
|
||||
"pdd_package": PDD_PACKAGE,
|
||||
"pdd_version": EXPECTED_PDD_VERSION,
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
"path": screenshot_path.name,
|
||||
"role": "quantity_state_raw_screenshot",
|
||||
"sha256": _sha256_file(screenshot_path),
|
||||
},
|
||||
{
|
||||
"path": hierarchy_path.name,
|
||||
"role": "quantity_state_raw_hierarchy_local_only",
|
||||
"sha256": _sha256_file(hierarchy_path),
|
||||
},
|
||||
{
|
||||
"path": app_path.name,
|
||||
"role": "quantity_state_app_identity",
|
||||
"sha256": _sha256_file(app_path),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
"""T-105 数量两态只读取证与后续生产边界的离线测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import base64
|
||||
from contextlib import redirect_stderr
|
||||
from functools import lru_cache
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from io import BytesIO, StringIO
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
|
||||
from cmbuyer_client.pdd.quantity_gate2_spike import (
|
||||
DECLARED_QUANTITIES,
|
||||
EXPECTED_GOODS_ID,
|
||||
QuantityGate2EvidenceCapturer,
|
||||
QuantityGate2EvidenceError,
|
||||
QuantityGate2EvidenceTimeoutError,
|
||||
QuantityGate2ReadDevice,
|
||||
)
|
||||
|
||||
|
||||
SERIAL = "192.168.0.173:5555"
|
||||
HIERARCHY = "<?xml version='1.0' encoding='UTF-8'?><hierarchy rotation='0'><node text='local raw page' /></hierarchy>"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _png_base64() -> str:
|
||||
raw = BytesIO()
|
||||
Image.new("RGB", (1080, 2376), color="white").save(raw, format="PNG")
|
||||
return base64.b64encode(raw.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
class FakeAdbClient:
|
||||
def __init__(self, *, model: str = "PKG110", android_version: str = "16") -> None:
|
||||
self.calls: list[str] = []
|
||||
self.inspection = DeviceInspection(
|
||||
device=AdbDevice(serial=SERIAL, state="device", model=model),
|
||||
model=model,
|
||||
android_version=android_version,
|
||||
)
|
||||
|
||||
def inspect(self, serial: str) -> DeviceInspection:
|
||||
self.calls.append(serial)
|
||||
return self.inspection
|
||||
|
||||
|
||||
class FakeReadDevice:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
version: str = "8.17.0",
|
||||
package: str = "com.xunmeng.pinduoduo",
|
||||
activity: str = "com.xunmeng.pinduoduo.activity.NewPageActivity",
|
||||
screen_size: tuple[int, int] = (1080, 2376),
|
||||
screenshot: object | None = None,
|
||||
hierarchy: object = HIERARCHY,
|
||||
) -> None:
|
||||
self.version = version
|
||||
self.package = package
|
||||
self.activity = activity
|
||||
self.screen_size = screen_size
|
||||
self.screenshot = _png_base64() if screenshot is None else screenshot
|
||||
self.hierarchy = hierarchy
|
||||
self.calls: list[tuple[object, ...]] = []
|
||||
self.app_info_reads = 0
|
||||
self.current_reads = 0
|
||||
self.window_reads = 0
|
||||
self.post_version: str | None = None
|
||||
self.post_package: str | None = None
|
||||
self.post_activity: str | None = None
|
||||
self.post_screen_size: tuple[int, int] | None = None
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, str]:
|
||||
self.calls.append(("app_info", package_name))
|
||||
self.app_info_reads += 1
|
||||
version = self.post_version if self.app_info_reads > 1 and self.post_version is not None else self.version
|
||||
return {"versionName": version}
|
||||
|
||||
def app_current(self) -> dict[str, str]:
|
||||
self.calls.append(("app_current",))
|
||||
self.current_reads += 1
|
||||
package = self.post_package if self.current_reads > 1 and self.post_package is not None else self.package
|
||||
activity = self.post_activity if self.current_reads > 1 and self.post_activity is not None else self.activity
|
||||
return {"package": package, "activity": activity}
|
||||
|
||||
def window_size(self) -> tuple[int, int]:
|
||||
self.calls.append(("window_size",))
|
||||
self.window_reads += 1
|
||||
if self.window_reads > 1 and self.post_screen_size is not None:
|
||||
return self.post_screen_size
|
||||
return self.screen_size
|
||||
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> object:
|
||||
self.calls.append(("jsonrpc", method, params, timeout))
|
||||
if method == "takeScreenshot":
|
||||
return self.screenshot
|
||||
if method == "dumpWindowHierarchy":
|
||||
return self.hierarchy
|
||||
raise AssertionError(f"unexpected RPC {method}")
|
||||
|
||||
|
||||
def _load_script() -> object:
|
||||
script_path = CLIENT_ROOT / "scripts" / "capture_quantity_gate2_spike.py"
|
||||
spec = spec_from_file_location("capture_quantity_gate2_spike_for_test", script_path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _namespace(**changes: object) -> argparse.Namespace:
|
||||
values: dict[str, object] = {
|
||||
"serial": SERIAL,
|
||||
"goods_id": EXPECTED_GOODS_ID,
|
||||
"state": "initial",
|
||||
"declared_quantity": 1,
|
||||
"output_dir": Path("evidence"),
|
||||
"timeout": 10.0,
|
||||
"adb": "adb",
|
||||
}
|
||||
values.update(changes)
|
||||
return argparse.Namespace(**values)
|
||||
|
||||
|
||||
class QuantityGate2EvidenceTests(unittest.TestCase):
|
||||
def _capturer(
|
||||
self,
|
||||
adb: FakeAdbClient,
|
||||
device: FakeReadDevice,
|
||||
*,
|
||||
connector_calls: list[str] | None = None,
|
||||
) -> QuantityGate2EvidenceCapturer:
|
||||
def connect(serial: str) -> FakeReadDevice:
|
||||
if connector_calls is not None:
|
||||
connector_calls.append(serial)
|
||||
return device
|
||||
|
||||
return QuantityGate2EvidenceCapturer(adb, connect, timeout_seconds=2)
|
||||
|
||||
def test_initial_and_target_states_publish_only_raw_read_evidence(self) -> None:
|
||||
for state, quantity in DECLARED_QUANTITIES.items():
|
||||
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeReadDevice()
|
||||
connector_calls: list[str] = []
|
||||
target = Path(temporary) / state
|
||||
|
||||
result = self._capturer(adb, device, connector_calls=connector_calls).capture(
|
||||
SERIAL,
|
||||
EXPECTED_GOODS_ID,
|
||||
state,
|
||||
quantity,
|
||||
target,
|
||||
)
|
||||
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
app = json.loads(result.app_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(adb.calls, [SERIAL])
|
||||
self.assertEqual(connector_calls, [SERIAL])
|
||||
self.assertEqual(
|
||||
[call[1] for call in device.calls if call[0] == "jsonrpc"],
|
||||
["takeScreenshot", "dumpWindowHierarchy"],
|
||||
)
|
||||
self.assertEqual(manifest["operation"], "t105-quantity-gate2-readonly-evidence")
|
||||
self.assertEqual(manifest["human_declared_state"], state)
|
||||
self.assertEqual(manifest["human_declared_quantity"], quantity)
|
||||
self.assertEqual(
|
||||
manifest["human_declared_selection"],
|
||||
{"color": "黑色CHA(纯棉)", "size": "M(建议100-115)"},
|
||||
)
|
||||
self.assertEqual(manifest["review_status"], "human_review_required")
|
||||
self.assertEqual(manifest["product"]["goods_id"], EXPECTED_GOODS_ID)
|
||||
self.assertEqual(app["package"], "com.xunmeng.pinduoduo")
|
||||
self.assertEqual(
|
||||
{path.name for path in target.iterdir()},
|
||||
{"screenshot.png", "hierarchy.xml", "app.json", "manifest.json"},
|
||||
)
|
||||
serialized = result.manifest_path.read_text(encoding="utf-8")
|
||||
self.assertNotIn(SERIAL, serialized)
|
||||
self.assertNotIn("NewPageActivity", serialized)
|
||||
self.assertNotIn("local raw page", serialized)
|
||||
for artifact in manifest["artifacts"]:
|
||||
self.assertEqual(len(artifact["sha256"]), 64)
|
||||
|
||||
def test_invalid_inputs_and_existing_target_fail_before_device_access(self) -> None:
|
||||
scenarios = (
|
||||
("", EXPECTED_GOODS_ID, "initial", 1),
|
||||
(f" {SERIAL}", EXPECTED_GOODS_ID, "initial", 1),
|
||||
(SERIAL, "958756616606", "initial", 1),
|
||||
(SERIAL, EXPECTED_GOODS_ID, "unknown", 1),
|
||||
(SERIAL, EXPECTED_GOODS_ID, "initial", 2),
|
||||
(SERIAL, EXPECTED_GOODS_ID, "target", 1),
|
||||
(SERIAL, EXPECTED_GOODS_ID, "initial", True),
|
||||
)
|
||||
for serial, goods_id, state, quantity in scenarios:
|
||||
with self.subTest(state=state, quantity=quantity), TemporaryDirectory() as temporary:
|
||||
adb = FakeAdbClient()
|
||||
connector_calls: list[str] = []
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
self._capturer(adb, FakeReadDevice(), connector_calls=connector_calls).capture(
|
||||
serial,
|
||||
goods_id,
|
||||
state,
|
||||
quantity,
|
||||
Path(temporary) / "evidence",
|
||||
)
|
||||
self.assertEqual(adb.calls, [])
|
||||
self.assertEqual(connector_calls, [])
|
||||
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
target.mkdir()
|
||||
sentinel = target / "sentinel.txt"
|
||||
sentinel.write_text("keep", encoding="utf-8")
|
||||
adb = FakeAdbClient()
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
self._capturer(adb, FakeReadDevice()).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, "initial", 1, target
|
||||
)
|
||||
self.assertEqual(adb.calls, [])
|
||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
||||
|
||||
def test_device_version_package_activity_and_screen_mismatch_fail_closed(self) -> None:
|
||||
scenarios = (
|
||||
(FakeAdbClient(model="OTHER"), FakeReadDevice()),
|
||||
(FakeAdbClient(android_version="15"), FakeReadDevice()),
|
||||
(FakeAdbClient(), FakeReadDevice(version="8.17.1")),
|
||||
(FakeAdbClient(), FakeReadDevice(package="com.example.other")),
|
||||
(FakeAdbClient(), FakeReadDevice(activity="")),
|
||||
(FakeAdbClient(), FakeReadDevice(screen_size=(1080, 2400))),
|
||||
)
|
||||
for adb, device in scenarios:
|
||||
with self.subTest(device=device.__dict__), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
self._capturer(adb, device).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, "initial", 1, target
|
||||
)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
self.assertEqual(
|
||||
[call for call in device.calls if call[0] == "jsonrpc"],
|
||||
[],
|
||||
)
|
||||
|
||||
def test_invalid_screenshot_or_hierarchy_leaves_no_partial_evidence(self) -> None:
|
||||
scenarios = (
|
||||
FakeReadDevice(screenshot="not base64"),
|
||||
FakeReadDevice(screenshot=123),
|
||||
FakeReadDevice(hierarchy="<not-hierarchy />"),
|
||||
FakeReadDevice(hierarchy=123),
|
||||
)
|
||||
for device in scenarios:
|
||||
with self.subTest(value=device.screenshot), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
self._capturer(FakeAdbClient(), device).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, "initial", 1, target
|
||||
)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_timeout_is_redacted_and_cleans_staging(self) -> None:
|
||||
class TimeoutDevice(FakeReadDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> object:
|
||||
self.calls.append(("jsonrpc", method, params, timeout))
|
||||
if method == "takeScreenshot":
|
||||
raise TimeoutError(f"secret {SERIAL} C:\\private\\evidence")
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(QuantityGate2EvidenceTimeoutError) as raised:
|
||||
self._capturer(FakeAdbClient(), TimeoutDevice()).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, "initial", 1, target
|
||||
)
|
||||
self.assertNotIn(SERIAL, str(raised.exception))
|
||||
self.assertNotIn("private", str(raised.exception))
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_post_capture_app_or_screen_drift_does_not_publish(self) -> None:
|
||||
drift_mutators = (
|
||||
lambda device: setattr(device, "post_version", "8.17.1"),
|
||||
lambda device: setattr(device, "post_package", "com.example.other"),
|
||||
lambda device: setattr(device, "post_activity", "OtherActivity"),
|
||||
lambda device: setattr(device, "post_screen_size", (1080, 2400)),
|
||||
)
|
||||
for mutate in drift_mutators:
|
||||
with TemporaryDirectory() as temporary:
|
||||
device = FakeReadDevice()
|
||||
mutate(device)
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
self._capturer(FakeAdbClient(), device).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, "initial", 1, target
|
||||
)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_capturer_is_one_shot_even_after_rejection(self) -> None:
|
||||
capturer = self._capturer(FakeAdbClient(), FakeReadDevice())
|
||||
with TemporaryDirectory() as temporary:
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
capturer.capture("", EXPECTED_GOODS_ID, "initial", 1, Path(temporary) / "bad")
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
capturer.capture(SERIAL, EXPECTED_GOODS_ID, "initial", 1, Path(temporary) / "good")
|
||||
|
||||
def test_read_protocol_and_capturer_expose_no_page_actions(self) -> None:
|
||||
forbidden = {
|
||||
"click",
|
||||
"swipe",
|
||||
"press",
|
||||
"pressKey",
|
||||
"send_keys",
|
||||
"set_text",
|
||||
"open_product",
|
||||
"open_sku_panel",
|
||||
"set_quantity",
|
||||
"go_to_order_confirm",
|
||||
"submit_order_once",
|
||||
"pay",
|
||||
}
|
||||
self.assertTrue(forbidden.isdisjoint(QuantityGate2ReadDevice.__dict__))
|
||||
self.assertEqual(
|
||||
{name for name in QuantityGate2EvidenceCapturer.__dict__ if not name.startswith("_")},
|
||||
{"capture"},
|
||||
)
|
||||
|
||||
|
||||
class QuantityGate2CliAndStaticBoundaryTests(unittest.TestCase):
|
||||
def test_cli_validation_rejects_all_unapproved_combinations(self) -> None:
|
||||
script = _load_script()
|
||||
invalid = (
|
||||
_namespace(serial=""),
|
||||
_namespace(serial=f" {SERIAL}"),
|
||||
_namespace(goods_id="958756616606"),
|
||||
_namespace(state="unknown"),
|
||||
_namespace(declared_quantity=2),
|
||||
_namespace(state="target", declared_quantity=1),
|
||||
_namespace(output_dir=Path("")),
|
||||
_namespace(timeout=0),
|
||||
_namespace(timeout=float("inf")),
|
||||
)
|
||||
for arguments in invalid:
|
||||
with self.subTest(arguments=arguments), self.assertRaises(ValueError):
|
||||
script.validate_arguments(arguments) # type: ignore[attr-defined]
|
||||
|
||||
def test_cli_parses_exact_initial_and_target_arguments(self) -> None:
|
||||
script = _load_script()
|
||||
for state, quantity in DECLARED_QUANTITIES.items():
|
||||
arguments = script.parse_arguments( # type: ignore[attr-defined]
|
||||
[
|
||||
"--serial",
|
||||
SERIAL,
|
||||
"--goods-id",
|
||||
EXPECTED_GOODS_ID,
|
||||
"--state",
|
||||
state,
|
||||
"--declared-quantity",
|
||||
str(quantity),
|
||||
"--output-dir",
|
||||
f"evidence-{state}",
|
||||
]
|
||||
)
|
||||
script.validate_arguments(arguments) # type: ignore[attr-defined]
|
||||
|
||||
def test_cli_runtime_failure_does_not_echo_sensitive_values(self) -> None:
|
||||
script = _load_script()
|
||||
error = QuantityGate2EvidenceError(f"secret {SERIAL} C:\\private\\evidence raw body")
|
||||
stderr = StringIO()
|
||||
fake_capturer = unittest.mock.Mock()
|
||||
fake_capturer.capture.side_effect = error
|
||||
with (
|
||||
patch.object(script, "QuantityGate2EvidenceCapturer", return_value=fake_capturer),
|
||||
patch.dict(sys.modules, {"adbutils": unittest.mock.Mock(), "uiautomator2": unittest.mock.Mock()}),
|
||||
redirect_stderr(stderr),
|
||||
):
|
||||
result = script.main(
|
||||
[
|
||||
"--serial",
|
||||
SERIAL,
|
||||
"--goods-id",
|
||||
EXPECTED_GOODS_ID,
|
||||
"--state",
|
||||
"initial",
|
||||
"--declared-quantity",
|
||||
"1",
|
||||
"--output-dir",
|
||||
"C:\\private\\evidence",
|
||||
]
|
||||
)
|
||||
self.assertEqual(result, 1)
|
||||
self.assertNotIn(SERIAL, stderr.getvalue())
|
||||
self.assertNotIn("private", stderr.getvalue())
|
||||
self.assertNotIn("raw body", stderr.getvalue())
|
||||
|
||||
def test_stage_one_sources_contain_only_approved_rpc_method_literals(self) -> None:
|
||||
expected_by_source = {
|
||||
Path("src/cmbuyer_client/pdd/quantity_gate2_spike.py"): {
|
||||
"takeScreenshot",
|
||||
"dumpWindowHierarchy",
|
||||
},
|
||||
Path("scripts/capture_quantity_gate2_spike.py"): set(),
|
||||
}
|
||||
for relative, expected in expected_by_source.items():
|
||||
tree = ast.parse((CLIENT_ROOT / relative).read_text(encoding="utf-8"))
|
||||
rpc_literals = {
|
||||
node.args[1].value
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "_read_rpc"
|
||||
and len(node.args) > 1
|
||||
and isinstance(node.args[1], ast.Constant)
|
||||
and isinstance(node.args[1].value, str)
|
||||
}
|
||||
self.assertEqual(rpc_literals, expected)
|
||||
forbidden_attributes = {
|
||||
node.attr
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Attribute)
|
||||
and node.attr in {"click", "swipe", "press", "pressKey", "set_text", "send_keys", "start_pdd_view_intent"}
|
||||
}
|
||||
self.assertEqual(forbidden_attributes, set())
|
||||
|
||||
def test_t103_and_t104_sources_do_not_import_quantity_gate2(self) -> None:
|
||||
forbidden_import = "quantity_gate2"
|
||||
sources = (
|
||||
CLIENT_ROOT / "src/cmbuyer_client/pdd/sku_selection.py",
|
||||
CLIENT_ROOT / "src/cmbuyer_client/pdd/sku_selection_runner.py",
|
||||
)
|
||||
for source in sources:
|
||||
self.assertNotIn(forbidden_import, source.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user