From 994b6c805499f6d70d25fcf2262c32d9343af60a Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 5 Aug 2026 18:08:11 +0800 Subject: [PATCH] feat(client): capture T-105 quantity states --- .../scripts/capture_quantity_gate2_spike.py | 117 +++++ .../pdd/quantity_gate2_spike.py | 303 ++++++++++++ client/tests/pdd/test_quantity_gate2.py | 452 ++++++++++++++++++ 3 files changed, 872 insertions(+) create mode 100644 client/scripts/capture_quantity_gate2_spike.py create mode 100644 client/src/cmbuyer_client/pdd/quantity_gate2_spike.py create mode 100644 client/tests/pdd/test_quantity_gate2.py diff --git a/client/scripts/capture_quantity_gate2_spike.py b/client/scripts/capture_quantity_gate2_spike.py new file mode 100644 index 0000000..426f6d3 --- /dev/null +++ b/client/scripts/capture_quantity_gate2_spike.py @@ -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()) diff --git a/client/src/cmbuyer_client/pdd/quantity_gate2_spike.py b/client/src/cmbuyer_client/pdd/quantity_gate2_spike.py new file mode 100644 index 0000000..9576272 --- /dev/null +++ b/client/src/cmbuyer_client/pdd/quantity_gate2_spike.py @@ -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), + }, + ], + } diff --git a/client/tests/pdd/test_quantity_gate2.py b/client/tests/pdd/test_quantity_gate2.py new file mode 100644 index 0000000..0343974 --- /dev/null +++ b/client/tests/pdd/test_quantity_gate2.py @@ -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 = "" + + +@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=""), + 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()