2026-08-05 18:08:11 +08:00
|
|
|
|
"""T-105 数量两态只读取证与后续生产边界的离线测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
import ast
|
|
|
|
|
|
import base64
|
|
|
|
|
|
from contextlib import redirect_stderr
|
2026-08-06 09:28:00 +08:00
|
|
|
|
from datetime import UTC, datetime
|
2026-08-05 18:08:11 +08:00
|
|
|
|
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"))
|
|
|
|
|
|
|
2026-08-06 08:52:42 +08:00
|
|
|
|
from cmbuyer_client.device.adb import AdbDevice, CommandResult, DeviceInspection
|
2026-08-05 18:08:11 +08:00
|
|
|
|
from cmbuyer_client.pdd.quantity_gate2_spike import (
|
2026-08-06 08:52:42 +08:00
|
|
|
|
Android16TopResumedForegroundReader,
|
2026-08-05 18:08:11 +08:00
|
|
|
|
DECLARED_QUANTITIES,
|
|
|
|
|
|
EXPECTED_GOODS_ID,
|
|
|
|
|
|
QuantityGate2EvidenceCapturer,
|
|
|
|
|
|
QuantityGate2EvidenceError,
|
|
|
|
|
|
QuantityGate2EvidenceTimeoutError,
|
2026-08-06 08:52:42 +08:00
|
|
|
|
QuantityGate2ForegroundReader,
|
2026-08-05 18:08:11 +08:00
|
|
|
|
QuantityGate2ReadDevice,
|
|
|
|
|
|
)
|
2026-08-06 09:28:00 +08:00
|
|
|
|
from cmbuyer_client.pdd.quantity_gate2 import (
|
|
|
|
|
|
Gate1Observation,
|
|
|
|
|
|
QuantityGate2Device,
|
|
|
|
|
|
QuantityGate2Error,
|
|
|
|
|
|
QuantityGate2Flow,
|
|
|
|
|
|
QuantityGate2OverCapError,
|
|
|
|
|
|
)
|
|
|
|
|
|
from cmbuyer_client.pdd.quantity_gate2_runner import QuantityGate2Runner
|
2026-08-05 18:08:11 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SERIAL = "192.168.0.173:5555"
|
|
|
|
|
|
HIERARCHY = "<?xml version='1.0' encoding='UTF-8'?><hierarchy rotation='0'><node text='local raw page' /></hierarchy>"
|
2026-08-06 08:52:42 +08:00
|
|
|
|
TOP_RESUMED = (
|
|
|
|
|
|
" topResumedActivity=ActivityRecord{101034589 u0 "
|
|
|
|
|
|
"com.xunmeng.pinduoduo/.activity.NewPageActivity t1816}\n"
|
|
|
|
|
|
)
|
2026-08-05 18:08:11 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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",
|
|
|
|
|
|
screen_size: tuple[int, int] = (1080, 2376),
|
|
|
|
|
|
screenshot: object | None = None,
|
|
|
|
|
|
hierarchy: object = HIERARCHY,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
self.version = version
|
|
|
|
|
|
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.window_reads = 0
|
|
|
|
|
|
self.post_version: 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 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}")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 08:52:42 +08:00
|
|
|
|
class FakeForegroundReader:
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
package: str = "com.xunmeng.pinduoduo",
|
|
|
|
|
|
activity: str = ".activity.NewPageActivity",
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
self.package = package
|
|
|
|
|
|
self.activity = activity
|
|
|
|
|
|
self.post_package: str | None = None
|
|
|
|
|
|
self.post_activity: str | None = None
|
|
|
|
|
|
self.calls: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
def read(self, serial: str) -> dict[str, str]:
|
|
|
|
|
|
self.calls.append(serial)
|
|
|
|
|
|
package = self.post_package if len(self.calls) > 1 and self.post_package is not None else self.package
|
|
|
|
|
|
activity = self.post_activity if len(self.calls) > 1 and self.post_activity is not None else self.activity
|
|
|
|
|
|
return {"package": package, "activity": activity}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeCommandRunner:
|
|
|
|
|
|
def __init__(self, result: CommandResult) -> None:
|
|
|
|
|
|
self.result = result
|
|
|
|
|
|
self.calls: list[tuple[tuple[str, ...], float]] = []
|
|
|
|
|
|
|
|
|
|
|
|
def run(self, arguments: tuple[str, ...], timeout_seconds: float) -> CommandResult:
|
|
|
|
|
|
self.calls.append((arguments, timeout_seconds))
|
|
|
|
|
|
return self.result
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 18:08:11 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 09:28:00 +08:00
|
|
|
|
def _load_run_script() -> object:
|
|
|
|
|
|
script_path = CLIENT_ROOT / "scripts" / "run_t105_quantity_gate2.py"
|
|
|
|
|
|
spec = spec_from_file_location("run_t105_quantity_gate2_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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 18:08:11 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 08:52:42 +08:00
|
|
|
|
class Android16TopResumedForegroundReaderTests(unittest.TestCase):
|
|
|
|
|
|
def test_reads_exact_unique_android16_top_resumed_activity(self) -> None:
|
|
|
|
|
|
runner = FakeCommandRunner(CommandResult(stdout=TOP_RESUMED))
|
|
|
|
|
|
reader = Android16TopResumedForegroundReader(runner, timeout_seconds=7)
|
|
|
|
|
|
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
reader.read(SERIAL),
|
|
|
|
|
|
{
|
|
|
|
|
|
"package": "com.xunmeng.pinduoduo",
|
|
|
|
|
|
"activity": ".activity.NewPageActivity",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
runner.calls,
|
|
|
|
|
|
[
|
|
|
|
|
|
(
|
|
|
|
|
|
(
|
|
|
|
|
|
"-s",
|
|
|
|
|
|
SERIAL,
|
|
|
|
|
|
"shell",
|
|
|
|
|
|
"dumpsys",
|
|
|
|
|
|
"activity",
|
|
|
|
|
|
"activities",
|
|
|
|
|
|
),
|
|
|
|
|
|
7,
|
|
|
|
|
|
)
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_legacy_or_duplicate_top_resumed_activity_is_rejected(self) -> None:
|
|
|
|
|
|
rejected_outputs = (
|
|
|
|
|
|
"",
|
|
|
|
|
|
(
|
|
|
|
|
|
"mResumedActivity: ActivityRecord{101034589 u0 "
|
|
|
|
|
|
"com.xunmeng.pinduoduo/.activity.NewPageActivity t1816}\n"
|
|
|
|
|
|
),
|
|
|
|
|
|
TOP_RESUMED + TOP_RESUMED,
|
|
|
|
|
|
"topResumedActivity=null\n",
|
|
|
|
|
|
)
|
|
|
|
|
|
for output in rejected_outputs:
|
|
|
|
|
|
with self.subTest(output=output):
|
|
|
|
|
|
reader = Android16TopResumedForegroundReader(
|
|
|
|
|
|
FakeCommandRunner(CommandResult(stdout=output)),
|
|
|
|
|
|
timeout_seconds=7,
|
|
|
|
|
|
)
|
|
|
|
|
|
with self.assertRaises(QuantityGate2EvidenceError):
|
|
|
|
|
|
reader.read(SERIAL)
|
|
|
|
|
|
|
|
|
|
|
|
def test_command_failure_and_invalid_serial_are_rejected(self) -> None:
|
|
|
|
|
|
runner = FakeCommandRunner(CommandResult(stdout=TOP_RESUMED, returncode=1))
|
|
|
|
|
|
reader = Android16TopResumedForegroundReader(runner, timeout_seconds=7)
|
|
|
|
|
|
with self.assertRaises(QuantityGate2EvidenceError):
|
|
|
|
|
|
reader.read(SERIAL)
|
|
|
|
|
|
with self.assertRaises(QuantityGate2EvidenceError):
|
|
|
|
|
|
reader.read(f" {SERIAL}")
|
|
|
|
|
|
self.assertEqual(len(runner.calls), 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 18:08:11 +08:00
|
|
|
|
class QuantityGate2EvidenceTests(unittest.TestCase):
|
|
|
|
|
|
def _capturer(
|
|
|
|
|
|
self,
|
|
|
|
|
|
adb: FakeAdbClient,
|
|
|
|
|
|
device: FakeReadDevice,
|
|
|
|
|
|
*,
|
2026-08-06 08:52:42 +08:00
|
|
|
|
foreground: FakeForegroundReader | None = None,
|
2026-08-05 18:08:11 +08:00
|
|
|
|
connector_calls: list[str] | None = None,
|
|
|
|
|
|
) -> QuantityGate2EvidenceCapturer:
|
|
|
|
|
|
def connect(serial: str) -> FakeReadDevice:
|
|
|
|
|
|
if connector_calls is not None:
|
|
|
|
|
|
connector_calls.append(serial)
|
|
|
|
|
|
return device
|
|
|
|
|
|
|
2026-08-06 08:52:42 +08:00
|
|
|
|
return QuantityGate2EvidenceCapturer(
|
|
|
|
|
|
adb,
|
|
|
|
|
|
connect,
|
|
|
|
|
|
foreground or FakeForegroundReader(),
|
|
|
|
|
|
timeout_seconds=2,
|
|
|
|
|
|
)
|
2026-08-05 18:08:11 +08:00
|
|
|
|
|
|
|
|
|
|
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()
|
2026-08-06 08:52:42 +08:00
|
|
|
|
foreground = FakeForegroundReader()
|
2026-08-05 18:08:11 +08:00
|
|
|
|
connector_calls: list[str] = []
|
|
|
|
|
|
target = Path(temporary) / state
|
|
|
|
|
|
|
2026-08-06 08:52:42 +08:00
|
|
|
|
result = self._capturer(
|
|
|
|
|
|
adb,
|
|
|
|
|
|
device,
|
|
|
|
|
|
foreground=foreground,
|
|
|
|
|
|
connector_calls=connector_calls,
|
|
|
|
|
|
).capture(
|
2026-08-05 18:08:11 +08:00
|
|
|
|
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])
|
2026-08-06 08:52:42 +08:00
|
|
|
|
self.assertEqual(foreground.calls, [SERIAL, SERIAL])
|
2026-08-05 18:08:11 +08:00
|
|
|
|
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 = (
|
2026-08-06 08:52:42 +08:00
|
|
|
|
(FakeAdbClient(model="OTHER"), FakeReadDevice(), FakeForegroundReader()),
|
|
|
|
|
|
(FakeAdbClient(android_version="15"), FakeReadDevice(), FakeForegroundReader()),
|
|
|
|
|
|
(FakeAdbClient(), FakeReadDevice(version="8.17.1"), FakeForegroundReader()),
|
|
|
|
|
|
(FakeAdbClient(), FakeReadDevice(), FakeForegroundReader(package="com.example.other")),
|
|
|
|
|
|
(FakeAdbClient(), FakeReadDevice(), FakeForegroundReader(activity="")),
|
|
|
|
|
|
(FakeAdbClient(), FakeReadDevice(screen_size=(1080, 2400)), FakeForegroundReader()),
|
2026-08-05 18:08:11 +08:00
|
|
|
|
)
|
2026-08-06 08:52:42 +08:00
|
|
|
|
for adb, device, foreground in scenarios:
|
2026-08-05 18:08:11 +08:00
|
|
|
|
with self.subTest(device=device.__dict__), TemporaryDirectory() as temporary:
|
|
|
|
|
|
target = Path(temporary) / "evidence"
|
|
|
|
|
|
with self.assertRaises(QuantityGate2EvidenceError):
|
2026-08-06 08:52:42 +08:00
|
|
|
|
self._capturer(adb, device, foreground=foreground).capture(
|
2026-08-05 18:08:11 +08:00
|
|
|
|
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 = (
|
2026-08-06 08:52:42 +08:00
|
|
|
|
lambda device, foreground: setattr(device, "post_version", "8.17.1"),
|
|
|
|
|
|
lambda device, foreground: setattr(foreground, "post_package", "com.example.other"),
|
|
|
|
|
|
lambda device, foreground: setattr(foreground, "post_activity", "OtherActivity"),
|
|
|
|
|
|
lambda device, foreground: setattr(device, "post_screen_size", (1080, 2400)),
|
2026-08-05 18:08:11 +08:00
|
|
|
|
)
|
|
|
|
|
|
for mutate in drift_mutators:
|
|
|
|
|
|
with TemporaryDirectory() as temporary:
|
|
|
|
|
|
device = FakeReadDevice()
|
2026-08-06 08:52:42 +08:00
|
|
|
|
foreground = FakeForegroundReader()
|
|
|
|
|
|
mutate(device, foreground)
|
2026-08-05 18:08:11 +08:00
|
|
|
|
target = Path(temporary) / "evidence"
|
|
|
|
|
|
with self.assertRaises(QuantityGate2EvidenceError):
|
2026-08-06 08:52:42 +08:00
|
|
|
|
self._capturer(
|
|
|
|
|
|
FakeAdbClient(),
|
|
|
|
|
|
device,
|
|
|
|
|
|
foreground=foreground,
|
|
|
|
|
|
).capture(
|
2026-08-05 18:08:11 +08:00
|
|
|
|
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__))
|
2026-08-06 08:52:42 +08:00
|
|
|
|
self.assertTrue(forbidden.isdisjoint(QuantityGate2ForegroundReader.__dict__))
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
{
|
|
|
|
|
|
name
|
|
|
|
|
|
for name in Android16TopResumedForegroundReader.__dict__
|
|
|
|
|
|
if not name.startswith("_")
|
|
|
|
|
|
},
|
|
|
|
|
|
{"read"},
|
|
|
|
|
|
)
|
2026-08-05 18:08:11 +08:00
|
|
|
|
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"))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 09:28:00 +08:00
|
|
|
|
_INITIAL_FIXTURE = CLIENT_ROOT / "tests" / "pdd" / "fixtures" / "quantity_gate2_initial_8_17_0.xml"
|
|
|
|
|
|
_TARGET_FIXTURE = CLIENT_ROOT / "tests" / "pdd" / "fixtures" / "quantity_gate2_target_8_17_0.xml"
|
|
|
|
|
|
_EXIT_FIXTURE = CLIENT_ROOT / "tests" / "pdd" / "fixtures" / "product_exit_8_17_0.xml"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _gate1(path: Path = Path("gate1.png")) -> Gate1Observation:
|
|
|
|
|
|
return Gate1Observation(
|
|
|
|
|
|
color="黑色CHA(纯棉)",
|
|
|
|
|
|
size="M(建议100-115)",
|
|
|
|
|
|
quantity=1,
|
|
|
|
|
|
gate1_unit_price="12.88",
|
|
|
|
|
|
screenshot_path=path,
|
|
|
|
|
|
captured_at=datetime(2026, 8, 6, 0, 53, tzinfo=UTC),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeQuantityFlowDevice:
|
|
|
|
|
|
def __init__(self, initial: str | None = None, target: str | None = None) -> None:
|
|
|
|
|
|
self.initial = initial or _INITIAL_FIXTURE.read_text(encoding="utf-8")
|
|
|
|
|
|
self.target = target or _TARGET_FIXTURE.read_text(encoding="utf-8")
|
|
|
|
|
|
self.exit = _EXIT_FIXTURE.read_text(encoding="utf-8")
|
|
|
|
|
|
self.current = self.initial
|
|
|
|
|
|
self.version = "8.17.0"
|
|
|
|
|
|
self.foreground = {"package": "com.xunmeng.pinduoduo", "activity": ".activity.NewPageActivity"}
|
|
|
|
|
|
self.screen_size = (1080, 2376)
|
|
|
|
|
|
self.increment_calls: list[str] = []
|
|
|
|
|
|
self.back_calls = 0
|
|
|
|
|
|
self.dump_calls = 0
|
|
|
|
|
|
self.raise_increment: BaseException | None = None
|
|
|
|
|
|
|
|
|
|
|
|
def app_info(self, package_name: str) -> dict[str, str]:
|
|
|
|
|
|
return {"versionName": self.version}
|
|
|
|
|
|
|
|
|
|
|
|
def current_foreground(self) -> dict[str, str]:
|
|
|
|
|
|
return dict(self.foreground)
|
|
|
|
|
|
|
|
|
|
|
|
def display_size(self) -> tuple[int, int]:
|
|
|
|
|
|
return self.screen_size
|
|
|
|
|
|
|
|
|
|
|
|
def dump_window_hierarchy(self) -> str:
|
|
|
|
|
|
self.dump_calls += 1
|
|
|
|
|
|
return self.current
|
|
|
|
|
|
|
|
|
|
|
|
def increment_quantity_once(self, bounds: str) -> None:
|
|
|
|
|
|
self.increment_calls.append(bounds)
|
|
|
|
|
|
if self.raise_increment is not None:
|
|
|
|
|
|
raise self.raise_increment
|
|
|
|
|
|
self.current = self.target
|
|
|
|
|
|
|
|
|
|
|
|
def capture_screenshot(self) -> str:
|
|
|
|
|
|
return _png_base64()
|
|
|
|
|
|
|
|
|
|
|
|
def leave_sku_panel_once(self) -> None:
|
|
|
|
|
|
self.back_calls += 1
|
|
|
|
|
|
self.current = self.exit
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class QuantityGate2FlowTests(unittest.TestCase):
|
|
|
|
|
|
def test_quantity_two_reads_nonlinear_panel_total_and_exits_once(self) -> None:
|
|
|
|
|
|
device = FakeQuantityFlowDevice()
|
|
|
|
|
|
flow = QuantityGate2Flow(device, wait_timeout_seconds=0.1, poll_interval_seconds=0.01)
|
|
|
|
|
|
verified = flow.set_quantity_and_verify(_gate1(), 2, "40.00")
|
|
|
|
|
|
observation = flow.build_observation(
|
|
|
|
|
|
_gate1(),
|
|
|
|
|
|
2,
|
|
|
|
|
|
"40.00",
|
|
|
|
|
|
Path("gate2.png"),
|
|
|
|
|
|
datetime(2026, 8, 6, 1, 10, tzinfo=UTC),
|
|
|
|
|
|
)
|
|
|
|
|
|
flow.exit_sku_panel_safely()
|
|
|
|
|
|
|
|
|
|
|
|
self.assertEqual(verified.quantity, 2)
|
|
|
|
|
|
self.assertEqual(observation.quantity_read, 2)
|
|
|
|
|
|
self.assertEqual(observation.gate1_unit_price, "12.88")
|
|
|
|
|
|
self.assertEqual(observation.gate2_panel_total_price, "32.76")
|
|
|
|
|
|
self.assertEqual(observation.max_total_price, "40.00")
|
|
|
|
|
|
self.assertEqual(device.increment_calls, ["[567,752][645,827]"])
|
|
|
|
|
|
self.assertEqual(device.back_calls, 1)
|
|
|
|
|
|
self.assertTrue(flow.exited)
|
|
|
|
|
|
|
|
|
|
|
|
def test_quantity_one_is_zero_click_and_still_uses_panel_amount(self) -> None:
|
|
|
|
|
|
device = FakeQuantityFlowDevice()
|
|
|
|
|
|
flow = QuantityGate2Flow(device)
|
|
|
|
|
|
verified = flow.set_quantity_and_verify(_gate1(), 1, "12.88")
|
|
|
|
|
|
self.assertEqual((verified.quantity, verified.panel_total_price), (1, "12.88"))
|
|
|
|
|
|
self.assertEqual(device.increment_calls, [])
|
|
|
|
|
|
|
|
|
|
|
|
def test_only_evidenced_quantities_and_canonical_decimal_cap_are_allowed(self) -> None:
|
|
|
|
|
|
for quantity in (0, 3, -1, True, "2"):
|
|
|
|
|
|
with self.subTest(quantity=quantity):
|
|
|
|
|
|
device = FakeQuantityFlowDevice()
|
|
|
|
|
|
with self.assertRaises(QuantityGate2Error):
|
|
|
|
|
|
QuantityGate2Flow(device).set_quantity_and_verify(_gate1(), quantity, "40.00") # type: ignore[arg-type]
|
|
|
|
|
|
self.assertEqual(device.increment_calls, [])
|
|
|
|
|
|
for cap in ("0.00", "40", "040.00", 40.0, "NaN"):
|
|
|
|
|
|
with self.subTest(cap=cap):
|
|
|
|
|
|
device = FakeQuantityFlowDevice()
|
|
|
|
|
|
with self.assertRaises(QuantityGate2Error):
|
|
|
|
|
|
QuantityGate2Flow(device).set_quantity_and_verify(_gate1(), 2, cap) # type: ignore[arg-type]
|
|
|
|
|
|
self.assertEqual(device.increment_calls, [])
|
|
|
|
|
|
|
|
|
|
|
|
def test_over_cap_stops_after_exact_readback_without_fabricated_math(self) -> None:
|
|
|
|
|
|
device = FakeQuantityFlowDevice()
|
|
|
|
|
|
flow = QuantityGate2Flow(device)
|
|
|
|
|
|
with self.assertRaises(QuantityGate2OverCapError):
|
|
|
|
|
|
flow.set_quantity_and_verify(_gate1(), 2, "30.00")
|
|
|
|
|
|
self.assertEqual(device.increment_calls, ["[567,752][645,827]"])
|
|
|
|
|
|
self.assertTrue(flow.can_exit_safely)
|
|
|
|
|
|
|
|
|
|
|
|
def test_increment_timeout_is_sealed_and_cannot_be_retried(self) -> None:
|
|
|
|
|
|
device = FakeQuantityFlowDevice()
|
|
|
|
|
|
device.raise_increment = TimeoutError("ambiguous delivery")
|
|
|
|
|
|
flow = QuantityGate2Flow(device, wait_timeout_seconds=0.01, poll_interval_seconds=0.005)
|
|
|
|
|
|
with self.assertRaises(TimeoutError):
|
|
|
|
|
|
flow.set_quantity_and_verify(_gate1(), 2, "40.00")
|
|
|
|
|
|
with self.assertRaises(QuantityGate2Error):
|
|
|
|
|
|
flow.set_quantity_and_verify(_gate1(), 2, "40.00")
|
|
|
|
|
|
self.assertEqual(len(device.increment_calls), 1)
|
|
|
|
|
|
self.assertFalse(flow.can_exit_safely)
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_duplicate_drift_and_overlay_fail_before_click(self) -> None:
|
|
|
|
|
|
initial = _INITIAL_FIXTURE.read_text(encoding="utf-8")
|
|
|
|
|
|
plus = '<node content-desc="增加数量" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[567,752][645,827]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />'
|
|
|
|
|
|
variants = (
|
|
|
|
|
|
initial.replace('content-desc="增加数量"', 'content-desc="数量加一"', 1),
|
|
|
|
|
|
initial.replace(plus, plus + plus),
|
|
|
|
|
|
initial.replace('text="M(建议100-115)"', 'text="S(建议80-100)"', 1),
|
|
|
|
|
|
initial.replace(
|
|
|
|
|
|
"</hierarchy>",
|
|
|
|
|
|
'<node package="com.example.overlay" class="android.view.ViewGroup" bounds="[560,740][660,840]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" /></hierarchy>',
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
for hierarchy in variants:
|
|
|
|
|
|
with self.subTest(marker=hierarchy[-180:]):
|
|
|
|
|
|
device = FakeQuantityFlowDevice(initial=hierarchy)
|
|
|
|
|
|
with self.assertRaises(QuantityGate2Error):
|
|
|
|
|
|
QuantityGate2Flow(device).set_quantity_and_verify(_gate1(), 2, "40.00")
|
|
|
|
|
|
self.assertEqual(device.increment_calls, [])
|
|
|
|
|
|
|
|
|
|
|
|
def test_foreground_version_and_screen_drift_are_zero_click(self) -> None:
|
|
|
|
|
|
devices = (FakeQuantityFlowDevice(), FakeQuantityFlowDevice(), FakeQuantityFlowDevice())
|
|
|
|
|
|
devices[0].foreground = {"package": "com.android.systemui", "activity": ".Keyguard"}
|
|
|
|
|
|
devices[1].version = "8.17.1"
|
|
|
|
|
|
devices[2].screen_size = (1080, 2400)
|
|
|
|
|
|
for device in devices:
|
|
|
|
|
|
with self.subTest(device=device):
|
|
|
|
|
|
with self.assertRaises(QuantityGate2Error):
|
|
|
|
|
|
QuantityGate2Flow(device).set_quantity_and_verify(_gate1(), 2, "40.00")
|
|
|
|
|
|
self.assertEqual(device.increment_calls, [])
|
|
|
|
|
|
|
|
|
|
|
|
def test_bottom_submit_amount_is_never_a_gate2_candidate(self) -> None:
|
|
|
|
|
|
target = _TARGET_FIXTURE.read_text(encoding="utf-8").replace("提交订单 ¥32.76", "提交订单 ¥999.99")
|
|
|
|
|
|
device = FakeQuantityFlowDevice(target=target)
|
|
|
|
|
|
result = QuantityGate2Flow(device).set_quantity_and_verify(_gate1(), 2, "40.00")
|
|
|
|
|
|
self.assertEqual(result.panel_total_price, "32.76")
|
|
|
|
|
|
|
|
|
|
|
|
def test_gate1_target_spec_and_timestamp_are_strict(self) -> None:
|
|
|
|
|
|
invalid = (
|
|
|
|
|
|
{"color": "白色"},
|
|
|
|
|
|
{"size": "L"},
|
|
|
|
|
|
{"quantity": 2},
|
|
|
|
|
|
{"gate1_unit_price": "12.89"},
|
|
|
|
|
|
{"captured_at": datetime(2026, 8, 6, 0, 53)},
|
|
|
|
|
|
)
|
|
|
|
|
|
base = _gate1().__dict__
|
|
|
|
|
|
for change in invalid:
|
|
|
|
|
|
with self.subTest(change=change), self.assertRaises(QuantityGate2Error):
|
|
|
|
|
|
Gate1Observation(**{**base, **change})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeRawQuantityDevice:
|
|
|
|
|
|
def __init__(self, *, click_timeout: bool = False) -> None:
|
|
|
|
|
|
self.current = _INITIAL_FIXTURE.read_text(encoding="utf-8")
|
|
|
|
|
|
self.target = _TARGET_FIXTURE.read_text(encoding="utf-8")
|
|
|
|
|
|
self.exit = _EXIT_FIXTURE.read_text(encoding="utf-8")
|
|
|
|
|
|
self.calls: list[tuple[object, ...]] = []
|
|
|
|
|
|
self.click_timeout = click_timeout
|
|
|
|
|
|
|
|
|
|
|
|
def app_info(self, package: str) -> dict[str, str]:
|
|
|
|
|
|
self.calls.append(("app_info", package))
|
|
|
|
|
|
return {"versionName": "8.17.0"}
|
|
|
|
|
|
|
|
|
|
|
|
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) -> object:
|
|
|
|
|
|
self.calls.append(("jsonrpc", method, params, timeout))
|
|
|
|
|
|
if method == "dumpWindowHierarchy":
|
|
|
|
|
|
return self.current
|
|
|
|
|
|
if method == "click":
|
|
|
|
|
|
self.current = self.target
|
|
|
|
|
|
if self.click_timeout:
|
|
|
|
|
|
raise TimeoutError("may have been delivered")
|
|
|
|
|
|
return None
|
|
|
|
|
|
if method == "takeScreenshot":
|
|
|
|
|
|
return _png_base64()
|
|
|
|
|
|
if method == "pressKey":
|
|
|
|
|
|
self.current = self.exit
|
|
|
|
|
|
return None
|
|
|
|
|
|
raise AssertionError(method)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class QuantityGate2RunnerTests(unittest.TestCase):
|
|
|
|
|
|
def test_runner_publishes_gate2_evidence_then_one_safe_exit(self) -> None:
|
|
|
|
|
|
with TemporaryDirectory() as temporary:
|
|
|
|
|
|
base = Path(temporary)
|
|
|
|
|
|
gate1_path = base / "gate1.png"
|
|
|
|
|
|
gate1_path.write_bytes(base64.b64decode(_png_base64()))
|
|
|
|
|
|
target = base / "gate2"
|
|
|
|
|
|
raw = FakeRawQuantityDevice()
|
|
|
|
|
|
result = QuantityGate2Runner(
|
|
|
|
|
|
FakeAdbClient(),
|
|
|
|
|
|
lambda serial: raw,
|
|
|
|
|
|
FakeForegroundReader(),
|
|
|
|
|
|
timeout_seconds=0.1,
|
|
|
|
|
|
).run(SERIAL, EXPECTED_GOODS_ID, _gate1(gate1_path), 2, "40.00", target)
|
|
|
|
|
|
|
|
|
|
|
|
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
|
|
|
|
|
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
|
|
|
|
|
|
self.assertEqual(methods.count("click"), 1)
|
|
|
|
|
|
self.assertEqual(methods.count("pressKey"), 1)
|
|
|
|
|
|
self.assertEqual(methods.count("takeScreenshot"), 1)
|
|
|
|
|
|
self.assertEqual(result.observation.gate2_panel_total_price, "32.76")
|
|
|
|
|
|
self.assertEqual(manifest["prices"]["gate1_unit_price"], "12.88")
|
|
|
|
|
|
self.assertEqual(manifest["prices"]["gate2_panel_total_price"], "32.76")
|
|
|
|
|
|
self.assertEqual(manifest["prices"]["max_total_price"], "40.00")
|
|
|
|
|
|
self.assertEqual(manifest["action_audit"]["increment_attempts"], 1)
|
|
|
|
|
|
self.assertEqual(manifest["action_audit"]["back_attempts"], 1)
|
|
|
|
|
|
self.assertEqual(manifest["safe_exit"], "completed")
|
|
|
|
|
|
self.assertEqual(manifest["review_status"], "human_review_required")
|
|
|
|
|
|
serialized = result.manifest_path.read_text(encoding="utf-8")
|
|
|
|
|
|
self.assertNotIn(SERIAL, serialized)
|
|
|
|
|
|
self.assertNotIn(str(gate1_path), serialized)
|
|
|
|
|
|
|
|
|
|
|
|
def test_over_cap_attempts_one_safe_exit_and_publishes_nothing(self) -> None:
|
|
|
|
|
|
with TemporaryDirectory() as temporary:
|
|
|
|
|
|
base = Path(temporary)
|
|
|
|
|
|
gate1_path = base / "gate1.png"
|
|
|
|
|
|
gate1_path.write_bytes(base64.b64decode(_png_base64()))
|
|
|
|
|
|
raw = FakeRawQuantityDevice()
|
|
|
|
|
|
target = base / "gate2"
|
|
|
|
|
|
with self.assertRaises(QuantityGate2OverCapError):
|
|
|
|
|
|
QuantityGate2Runner(
|
|
|
|
|
|
FakeAdbClient(), lambda serial: raw, FakeForegroundReader(), timeout_seconds=0.1
|
|
|
|
|
|
).run(SERIAL, EXPECTED_GOODS_ID, _gate1(gate1_path), 2, "30.00", target)
|
|
|
|
|
|
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
|
|
|
|
|
|
self.assertEqual(methods.count("click"), 1)
|
|
|
|
|
|
self.assertEqual(methods.count("pressKey"), 1)
|
|
|
|
|
|
self.assertFalse(target.exists())
|
|
|
|
|
|
self.assertEqual(list(base.glob(".gate2.staging-*")), [])
|
|
|
|
|
|
|
|
|
|
|
|
def test_ambiguous_increment_is_never_retried_or_followed_by_back(self) -> None:
|
|
|
|
|
|
with TemporaryDirectory() as temporary:
|
|
|
|
|
|
base = Path(temporary)
|
|
|
|
|
|
gate1_path = base / "gate1.png"
|
|
|
|
|
|
gate1_path.write_bytes(base64.b64decode(_png_base64()))
|
|
|
|
|
|
raw = FakeRawQuantityDevice(click_timeout=True)
|
|
|
|
|
|
with self.assertRaises(QuantityGate2Error):
|
|
|
|
|
|
QuantityGate2Runner(
|
|
|
|
|
|
FakeAdbClient(), lambda serial: raw, FakeForegroundReader(), timeout_seconds=0.1
|
|
|
|
|
|
).run(SERIAL, EXPECTED_GOODS_ID, _gate1(gate1_path), 2, "40.00", base / "gate2")
|
|
|
|
|
|
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
|
|
|
|
|
|
self.assertEqual(methods.count("click"), 1)
|
|
|
|
|
|
self.assertEqual(methods.count("pressKey"), 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class QuantityGate2ProductionStaticBoundaryTests(unittest.TestCase):
|
|
|
|
|
|
def test_protocol_has_only_named_t105_actions(self) -> None:
|
|
|
|
|
|
public = {name for name in QuantityGate2Device.__dict__ if not name.startswith("_")}
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
public,
|
|
|
|
|
|
{
|
|
|
|
|
|
"app_info",
|
|
|
|
|
|
"current_foreground",
|
|
|
|
|
|
"display_size",
|
|
|
|
|
|
"dump_window_hierarchy",
|
|
|
|
|
|
"increment_quantity_once",
|
|
|
|
|
|
"capture_screenshot",
|
|
|
|
|
|
"leave_sku_panel_once",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_production_sources_have_no_old_gate2_field_or_forbidden_capability(self) -> None:
|
|
|
|
|
|
paths = (
|
|
|
|
|
|
CLIENT_ROOT / "src/cmbuyer_client/pdd/quantity_gate2.py",
|
|
|
|
|
|
CLIENT_ROOT / "src/cmbuyer_client/pdd/quantity_gate2_runner.py",
|
|
|
|
|
|
CLIENT_ROOT / "scripts/run_t105_quantity_gate2.py",
|
|
|
|
|
|
)
|
|
|
|
|
|
forbidden_attributes = {
|
|
|
|
|
|
"go_to_order_confirm",
|
|
|
|
|
|
"submit_order_once",
|
|
|
|
|
|
"start_pdd_view_intent",
|
|
|
|
|
|
"pay",
|
|
|
|
|
|
"swipe",
|
|
|
|
|
|
"set_text",
|
|
|
|
|
|
"send_keys",
|
|
|
|
|
|
}
|
|
|
|
|
|
for path in paths:
|
|
|
|
|
|
source = path.read_text(encoding="utf-8")
|
|
|
|
|
|
tree = ast.parse(source)
|
|
|
|
|
|
self.assertNotIn("gate2_unit_price", source)
|
|
|
|
|
|
self.assertFalse(
|
|
|
|
|
|
{
|
|
|
|
|
|
node.attr
|
|
|
|
|
|
for node in ast.walk(tree)
|
|
|
|
|
|
if isinstance(node, ast.Attribute) and node.attr in forbidden_attributes
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
quantity_tree = ast.parse(paths[0].read_text(encoding="utf-8"))
|
|
|
|
|
|
self.assertFalse(any(isinstance(node, ast.Mult) for node in ast.walk(quantity_tree)))
|
|
|
|
|
|
|
|
|
|
|
|
def test_t103_and_t104_still_do_not_import_t105(self) -> None:
|
|
|
|
|
|
for relative in (
|
|
|
|
|
|
"src/cmbuyer_client/pdd/sku_selection.py",
|
|
|
|
|
|
"src/cmbuyer_client/pdd/sku_selection_runner.py",
|
|
|
|
|
|
):
|
|
|
|
|
|
self.assertNotIn("quantity_gate2", (CLIENT_ROOT / relative).read_text(encoding="utf-8"))
|
|
|
|
|
|
|
|
|
|
|
|
def test_live_cli_fixes_the_human_review_case_and_redacts_runtime_failure(self) -> None:
|
|
|
|
|
|
script = _load_run_script()
|
|
|
|
|
|
with TemporaryDirectory() as temporary:
|
|
|
|
|
|
screenshot = Path(temporary) / "gate1.png"
|
|
|
|
|
|
screenshot.write_bytes(base64.b64decode(_png_base64()))
|
|
|
|
|
|
arguments = script.parse_arguments( # type: ignore[attr-defined]
|
|
|
|
|
|
[
|
|
|
|
|
|
"--serial", SERIAL,
|
|
|
|
|
|
"--goods-id", EXPECTED_GOODS_ID,
|
|
|
|
|
|
"--color", "黑色CHA(纯棉)",
|
|
|
|
|
|
"--size", "M(建议100-115)",
|
|
|
|
|
|
"--target-quantity", "2",
|
|
|
|
|
|
"--gate1-unit-price", "12.88",
|
|
|
|
|
|
"--gate1-screenshot", str(screenshot),
|
|
|
|
|
|
"--gate1-captured-at", "2026-08-06T00:53:00.023935+00:00",
|
|
|
|
|
|
"--max-total-price", "40.00",
|
|
|
|
|
|
"--output-dir", str(Path(temporary) / "gate2"),
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
script.validate_arguments(arguments) # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
|
|
|
stderr = StringIO()
|
|
|
|
|
|
fake_runner = unittest.mock.Mock()
|
|
|
|
|
|
fake_runner.run.side_effect = QuantityGate2Error(f"secret {SERIAL} {temporary}")
|
|
|
|
|
|
with (
|
|
|
|
|
|
patch.object(script, "QuantityGate2Runner", return_value=fake_runner),
|
|
|
|
|
|
patch.dict(sys.modules, {"adbutils": unittest.mock.Mock(), "uiautomator2": unittest.mock.Mock()}),
|
|
|
|
|
|
redirect_stderr(stderr),
|
|
|
|
|
|
):
|
|
|
|
|
|
result = script.main( # type: ignore[attr-defined]
|
|
|
|
|
|
[
|
|
|
|
|
|
"--serial", SERIAL,
|
|
|
|
|
|
"--goods-id", EXPECTED_GOODS_ID,
|
|
|
|
|
|
"--color", "黑色CHA(纯棉)",
|
|
|
|
|
|
"--size", "M(建议100-115)",
|
|
|
|
|
|
"--target-quantity", "2",
|
|
|
|
|
|
"--gate1-unit-price", "12.88",
|
|
|
|
|
|
"--gate1-screenshot", str(screenshot),
|
|
|
|
|
|
"--gate1-captured-at", "2026-08-06T00:53:00.023935+00:00",
|
|
|
|
|
|
"--max-total-price", "40.00",
|
|
|
|
|
|
"--output-dir", str(Path(temporary) / "gate2"),
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(result, 1)
|
|
|
|
|
|
self.assertNotIn(SERIAL, stderr.getvalue())
|
|
|
|
|
|
self.assertNotIn(temporary, stderr.getvalue())
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 18:08:11 +08:00
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
unittest.main()
|