feat(client): capture T-105 quantity states

This commit is contained in:
QiuSW
2026-08-05 18:08:11 +08:00
parent 16b2487a2a
commit 994b6c8054
3 changed files with 872 additions and 0 deletions
@@ -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),
},
],
}
+452
View File
@@ -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()