Files
cmbuyer/client/tests/pdd/test_final_submit_panel.py
T

685 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""T-107 合并式最终提交面板 Gate3 与一次安全返回测试。"""
from __future__ import annotations
import argparse
import ast
import base64
from contextlib import redirect_stderr
from dataclasses import FrozenInstanceError, replace
from datetime import UTC, datetime
from functools import lru_cache
from importlib.util import module_from_spec, spec_from_file_location
from io import BytesIO, StringIO
import inspect
import json
from pathlib import Path
import sys
from tempfile import TemporaryDirectory
import unittest
from unittest.mock import patch
from xml.etree import ElementTree
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.final_submit_panel import (
FinalSubmitPanelError,
FinalSubmitPanelOverCapError,
Gate3Observation,
observe_gate3,
returned_product_projection,
)
from cmbuyer_client.pdd.final_submit_panel_runner import (
FinalSubmitPanelDevice,
FinalSubmitPanelFlow,
FinalSubmitPanelRunner,
FinalSubmitPanelTimeoutError,
UiautomatorFinalSubmitPanelAdapter,
)
from cmbuyer_client.pdd.quantity_gate2 import (
EXPECTED_GOODS_ID,
TASK_COLOR,
TASK_SIZE,
Gate2Observation,
)
SERIAL = "192.168.0.173:5555"
FIXTURES = Path(__file__).with_name("fixtures")
PANEL_FIXTURE = FIXTURES / "final_submit_panel_8_17_0.xml"
EXIT_FIXTURE = FIXTURES / "final_submit_panel_exit_8_17_0.xml"
PANEL = PANEL_FIXTURE.read_text(encoding="utf-8")
EXIT = EXIT_FIXTURE.read_text(encoding="utf-8")
def _gate2(screenshot_path: Path = Path("gate2.png"), **changes: object) -> Gate2Observation:
value = Gate2Observation(
requested_color=TASK_COLOR,
requested_size=TASK_SIZE,
actual_color=TASK_COLOR,
actual_size=TASK_SIZE,
requested_quantity=2,
quantity_read=2,
gate1_unit_price="12.88",
gate2_panel_total_price="32.76",
max_total_price="40.00",
screenshot_path=screenshot_path,
captured_at=datetime(2026, 8, 6, 3, 7, 33, tzinfo=UTC),
)
return replace(value, **changes)
def _observe(raw: str = PANEL, gate2: Gate2Observation | None = None) -> Gate3Observation:
return observe_gate3(
raw,
_gate2() if gate2 is None else gate2,
Path("gate3.png"),
datetime(2026, 8, 6, 3, 40, tzinfo=UTC),
)
def _edit_one(raw: str, predicate: object, **attributes: str) -> str:
root = ElementTree.fromstring(raw)
matches = [node for node in root.iter("node") if predicate(node)] # type: ignore[operator]
if len(matches) != 1:
raise AssertionError(f"expected one node, got {len(matches)}")
for key, value in attributes.items():
matches[0].set(key.replace("_", "-"), value)
return ElementTree.tostring(root, encoding="unicode")
def _duplicate_submit(raw: str) -> str:
root = ElementTree.fromstring(raw)
submit = next(node for node in root.iter("node") if node.get("text") == "提交订单 ¥32.76")
parent = next(node for node in root.iter() if submit in list(node))
parent.append(ElementTree.fromstring(ElementTree.tostring(submit, encoding="unicode")))
return ElementTree.tostring(root, encoding="unicode")
@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 FinalSubmitPanelObserverTests(unittest.TestCase):
def test_observer_reads_two_independent_amount_roles_and_safe_control_summary(self) -> None:
observation = _observe()
self.assertEqual(observation.gate2_panel_total_price, "32.76")
self.assertEqual(observation.gate3_submit_amount, "32.76")
self.assertEqual(observation.submit_control_text, "提交订单 ¥32.76")
self.assertEqual(observation.submit_control_match_count, 1)
self.assertTrue(observation.submit_control_enabled)
self.assertTrue(observation.nearest_clickable_ancestor_unique)
self.assertEqual(observation.quantity_read, 2)
def test_dynamic_canonical_amount_is_allowed_only_when_both_roles_and_gate2_agree(self) -> None:
raw = PANEL.replace("快卖完 ¥32.76", "快卖完 ¥39.99").replace(
"提交订单 ¥32.76", "提交订单 ¥39.99"
)
observation = _observe(raw, _gate2(gate2_panel_total_price="39.99"))
self.assertEqual(observation.gate2_panel_total_price, "39.99")
self.assertEqual(observation.gate3_submit_amount, "39.99")
def test_gate2_top_role_must_match_trusted_gate2_observation(self) -> None:
raw = PANEL.replace("快卖完 ¥32.76", "快卖完 ¥32.77")
with self.assertRaises(FinalSubmitPanelError):
_observe(raw)
def test_gate3_must_strictly_equal_current_gate2_top_role(self) -> None:
raw = PANEL.replace("提交订单 ¥32.76", "提交订单 ¥32.77")
with self.assertRaises(FinalSubmitPanelError):
_observe(raw)
def test_over_cap_gate2_fails_before_page_result(self) -> None:
raw = PANEL.replace("快卖完 ¥32.76", "快卖完 ¥40.01").replace(
"提交订单 ¥32.76", "提交订单 ¥40.01"
)
with self.assertRaises(FinalSubmitPanelOverCapError):
_observe(raw, _gate2(gate2_panel_total_price="40.01"))
def test_noncanonical_money_never_becomes_a_candidate(self) -> None:
for text in ("提交订单 ¥032.76", "提交订单 ¥32.7", "提交订单 ¥ 32.76", "提交订单 32.76"):
with self.subTest(text=text), self.assertRaises(FinalSubmitPanelError):
_observe(PANEL.replace("提交订单 ¥32.76", text))
with self.assertRaises(FinalSubmitPanelError):
_observe(gate2=_gate2(gate2_panel_total_price="032.76"))
def test_submit_text_requires_exactly_one_complete_match(self) -> None:
with self.assertRaises(FinalSubmitPanelError):
_observe(PANEL.replace("提交订单 ¥32.76", "创建订单 ¥32.76"))
with self.assertRaises(FinalSubmitPanelError):
_observe(_duplicate_submit(PANEL))
def test_submit_leaf_must_be_inert_enabled_and_evidence_bound(self) -> None:
leaf = lambda node: node.get("text") == "提交订单 ¥32.76"
for change in (
{"clickable": "true"},
{"enabled": "false"},
{"bounds": "[365,2225][714,2284]"},
{"class": "android.widget.Button"},
):
with self.subTest(change=change), self.assertRaises(FinalSubmitPanelError):
_observe(_edit_one(PANEL, leaf, **change))
def test_nearest_clickable_ancestor_is_exact_but_broader_clickable_ancestor_is_allowed(self) -> None:
self.assertTrue(_observe().nearest_clickable_ancestor_unique)
frame = lambda node: node.get("class") == "android.widget.FrameLayout" and node.get("bounds") == "[0,2181][1080,2328]"
for change in ({"clickable": "false"}, {"enabled": "false"}, {"bounds": "[1,2181][1080,2328]"}):
with self.subTest(change=change), self.assertRaises(FinalSubmitPanelError):
_observe(_edit_one(PANEL, frame, **change))
def test_specs_quantity_and_panel_structure_are_exact(self) -> None:
cases = (
PANEL.replace("黑色 CHA (纯棉)", "白色 CHA (纯棉)"),
PANEL.replace("M(建议100-115)", "S(建议80-100)"),
_edit_one(PANEL, lambda node: node.get("class") == "android.widget.EditText", text="1"),
_edit_one(PANEL, lambda node: node.get("content-desc") == "增加数量", bounds="[568,827][645,902]"),
)
for raw in cases:
with self.subTest(), self.assertRaises(FinalSubmitPanelError):
_observe(raw)
def test_trusted_gate2_specs_quantity_and_evidence_metadata_are_required(self) -> None:
changes = (
{"actual_color": "白色"},
{"actual_size": "S"},
{"requested_quantity": 1},
{"quantity_read": 1},
{"screenshot_path": Path()},
{"captured_at": datetime(2026, 8, 6, 3, 7, 33)},
)
for change in changes:
with self.subTest(change=change), self.assertRaises(FinalSubmitPanelError):
_observe(gate2=_gate2(**change))
with self.assertRaises(FinalSubmitPanelError):
observe_gate3(PANEL, object(), Path("gate3.png"), datetime.now(UTC)) # type: ignore[arg-type]
def test_invalid_xml_and_root_fail_closed(self) -> None:
for raw in ("", "<hierarchy>", "<root />"):
with self.subTest(raw=raw), self.assertRaises(FinalSubmitPanelError):
_observe(raw)
def test_gate3_metadata_must_be_timezone_aware_and_named(self) -> None:
with self.assertRaises(FinalSubmitPanelError):
observe_gate3(PANEL, _gate2(), Path(), datetime.now(UTC))
with self.assertRaises(FinalSubmitPanelError):
observe_gate3(PANEL, _gate2(), Path("gate3.png"), datetime(2026, 8, 6))
def test_observation_is_frozen_and_contains_no_action_material(self) -> None:
observation = _observe()
expected = {
"requested_color", "requested_size", "actual_color", "actual_size",
"requested_quantity", "quantity_read", "gate2_panel_total_price",
"gate3_submit_amount", "max_total_price", "submit_control_text",
"submit_control_match_count", "submit_control_enabled",
"nearest_clickable_ancestor_unique", "screenshot_path", "captured_at",
}
self.assertEqual(set(observation.__dict__), expected)
for forbidden in ("selector", "bounds", "node", "ancestor_handle", "clickable_object"):
self.assertNotIn(forbidden, observation.__dict__)
with self.assertRaises(FrozenInstanceError):
observation.gate3_submit_amount = "1.00" # type: ignore[misc]
class ReturnedProductProjectionTests(unittest.TestCase):
def test_t106_return_fixture_matches_stable_same_product_projection(self) -> None:
first = returned_product_projection(EXIT)
second = returned_product_projection(EXIT)
self.assertEqual(first, second)
self.assertEqual(first[0], "final_submit_panel_exit_8_17_0")
def test_title_or_entry_drift_fails_closed(self) -> None:
cases = (
EXIT.replace("上衣淡人穿搭", "其他商品"),
EXIT.replace("免拼购买", "单独购买"),
EXIT.replace("快要抢光¥12.88", "快要抢光¥12.89"),
)
for raw in cases:
with self.subTest(), self.assertRaises(FinalSubmitPanelError):
returned_product_projection(raw)
def test_dangerous_live_action_rejects_return_page(self) -> None:
dangerous = (
'<node text="立即支付" package="com.xunmeng.pinduoduo" class="android.widget.TextView" '
'resource-id="" bounds="[0,0][1,1]" clickable="true" enabled="true" '
'visible-to-user="true" selected="false" scrollable="false" />'
)
raw = EXIT.replace("</hierarchy>", dangerous + "</hierarchy>")
with self.assertRaises(FinalSubmitPanelError):
returned_product_projection(raw)
class FakeClock:
def __init__(self) -> None:
self.value = 0.0
def __call__(self) -> float:
return self.value
def sleep(self, duration: float) -> None:
self.value += duration
class FakePanelDevice:
def __init__(
self,
*,
panel: str = PANEL,
exit_page: str = EXIT,
version: str = "8.17.0",
package: str = "com.xunmeng.pinduoduo",
size: tuple[int, int] = (1080, 2376),
back_error: Exception | None = None,
) -> None:
self.panel = panel
self.exit_page = exit_page
self.version = version
self.package = package
self.size = size
self.back_error = back_error
self.after_back = False
self.back_attempts = 0
self.calls: list[str] = []
def app_info(self, package_name: str) -> dict[str, str]:
self.calls.append("app_info")
return {"versionName": self.version}
def current_foreground(self) -> dict[str, str]:
self.calls.append("foreground")
return {"package": self.package, "activity": ".activity.NewPageActivity"}
def display_size(self) -> tuple[int, int]:
self.calls.append("size")
return self.size
def dump_window_hierarchy(self) -> str:
self.calls.append("dump")
return self.exit_page if self.after_back else self.panel
def capture_screenshot(self) -> str:
self.calls.append("screenshot")
return _png_base64()
def leave_final_submit_panel_once(self) -> None:
self.calls.append("back")
self.back_attempts += 1
self.after_back = True
if self.back_error is not None:
raise self.back_error
def _flow(device: FakePanelDevice, timeout: float = 0.02) -> FinalSubmitPanelFlow:
clock = FakeClock()
return FinalSubmitPanelFlow(
device,
wait_timeout_seconds=timeout,
poll_interval_seconds=0.005,
monotonic_clock=clock,
sleep_function=clock.sleep,
)
class FinalSubmitPanelFlowTests(unittest.TestCase):
def test_success_has_zero_page_control_actions_and_one_back(self) -> None:
device = FakePanelDevice()
flow = _flow(device)
observation = flow.observe(_gate2(), Path("gate3.png"), datetime.now(UTC))
flow.exit_final_submit_panel_safely()
self.assertEqual(observation.gate3_submit_amount, "32.76")
self.assertEqual(device.back_attempts, 1)
self.assertEqual(device.calls.count("back"), 1)
self.assertTrue(flow.exited)
self.assertFalse(hasattr(device, "click"))
def test_observation_failure_never_attempts_back(self) -> None:
device = FakePanelDevice(panel=PANEL.replace("提交订单 ¥32.76", "提交订单 ¥32.77"))
flow = _flow(device)
with self.assertRaises(FinalSubmitPanelError):
flow.observe(_gate2(), Path("gate3.png"), datetime.now(UTC))
self.assertEqual(device.back_attempts, 0)
with self.assertRaises(FinalSubmitPanelError):
flow.exit_final_submit_panel_safely()
def test_environment_drift_fails_before_back(self) -> None:
devices = (
FakePanelDevice(version="8.18.0"),
FakePanelDevice(package="com.android.systemui"),
FakePanelDevice(size=(1080, 2400)),
)
for device in devices:
with self.subTest(), self.assertRaises(FinalSubmitPanelError):
_flow(device).observe(_gate2(), Path("gate3.png"), datetime.now(UTC))
self.assertEqual(device.back_attempts, 0)
def test_back_result_unknown_is_not_retried(self) -> None:
device = FakePanelDevice(back_error=TimeoutError("unknown"))
flow = _flow(device)
flow.observe(_gate2(), Path("gate3.png"), datetime.now(UTC))
with self.assertRaises(TimeoutError):
flow.exit_final_submit_panel_safely()
self.assertEqual(device.back_attempts, 1)
with self.assertRaises(FinalSubmitPanelError):
flow.exit_final_submit_panel_safely()
self.assertEqual(device.back_attempts, 1)
def test_unconfirmed_return_times_out_after_exactly_one_back(self) -> None:
device = FakePanelDevice(exit_page=PANEL)
flow = _flow(device)
flow.observe(_gate2(), Path("gate3.png"), datetime.now(UTC))
with self.assertRaises(FinalSubmitPanelTimeoutError):
flow.exit_final_submit_panel_safely()
self.assertEqual(device.back_attempts, 1)
def test_single_positive_return_frame_then_drift_never_succeeds(self) -> None:
class AlternatingDevice(FakePanelDevice):
def dump_window_hierarchy(self) -> str:
self.calls.append("dump")
if not self.after_back:
return self.panel
return EXIT if self.calls.count("dump") % 2 == 1 else PANEL
device = AlternatingDevice()
flow = _flow(device)
flow.observe(_gate2(), Path("gate3.png"), datetime.now(UTC))
with self.assertRaises(FinalSubmitPanelTimeoutError):
flow.exit_final_submit_panel_safely()
self.assertEqual(device.back_attempts, 1)
class FakeForegroundReader:
def __init__(self, package: str = "com.xunmeng.pinduoduo") -> None:
self.package = package
self.calls: list[str] = []
def read(self, serial: str) -> dict[str, str]:
self.calls.append(serial)
return {"package": self.package, "activity": ".activity.NewPageActivity"}
class FakeRawDevice:
def __init__(self, *, version: str = "8.17.0", back_timeout: bool = False) -> None:
self.version = version
self.current = PANEL
self.calls: list[tuple[object, ...]] = []
self.back_timeout = back_timeout
def app_info(self, package: str) -> dict[str, str]:
self.calls.append(("app_info", package))
return {"versionName": self.version}
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 == "takeScreenshot":
return _png_base64()
if method == "pressKey":
self.current = EXIT
if self.back_timeout:
raise TimeoutError("may have been delivered")
return None
raise AssertionError(method)
class FakeAdbClient:
def __init__(self, *, model: str = "PKG110", android_version: str = "16") -> None:
self.inspection = DeviceInspection(
device=AdbDevice(serial=SERIAL, state="device", model=model),
model=model,
android_version=android_version,
)
self.calls: list[str] = []
def inspect(self, serial: str) -> DeviceInspection:
self.calls.append(serial)
return self.inspection
class FinalSubmitPanelRunnerTests(unittest.TestCase):
def test_runner_publishes_gate3_evidence_after_zero_control_actions_and_one_back(self) -> None:
with TemporaryDirectory() as temporary:
base = Path(temporary)
gate2_path = base / "gate2.png"
gate2_path.write_bytes(base64.b64decode(_png_base64()))
target = base / "gate3"
raw = FakeRawDevice()
result = FinalSubmitPanelRunner(
FakeAdbClient(),
lambda serial: raw,
FakeForegroundReader(),
timeout_seconds=0.2,
).run(SERIAL, EXPECTED_GOODS_ID, _gate2(gate2_path), 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.assertNotIn("click", methods)
self.assertEqual(methods.count("pressKey"), 1)
self.assertEqual(methods.count("takeScreenshot"), 1)
self.assertEqual(manifest["prices"]["gate2_panel_total_price"], "32.76")
self.assertEqual(manifest["prices"]["gate3_submit_amount"], "32.76")
self.assertEqual(manifest["submit_control"]["match_count"], 1)
self.assertTrue(manifest["submit_control"]["nearest_clickable_ancestor_unique"])
self.assertEqual(manifest["action_audit"]["page_control_action_attempts"], 0)
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(gate2_path), serialized)
def test_gate3_mismatch_publishes_nothing_and_never_attempts_back(self) -> None:
with TemporaryDirectory() as temporary:
base = Path(temporary)
gate2_path = base / "gate2.png"
gate2_path.write_bytes(base64.b64decode(_png_base64()))
raw = FakeRawDevice()
raw.current = PANEL.replace("提交订单 ¥32.76", "提交订单 ¥32.77")
target = base / "gate3"
with self.assertRaises(FinalSubmitPanelError):
FinalSubmitPanelRunner(
FakeAdbClient(), lambda serial: raw, FakeForegroundReader(), timeout_seconds=0.1
).run(SERIAL, EXPECTED_GOODS_ID, _gate2(gate2_path), target)
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
self.assertEqual(methods.count("pressKey"), 0)
self.assertFalse(target.exists())
self.assertEqual(list(base.glob(".gate3.staging-*")), [])
def test_ambiguous_back_is_attempted_once_and_never_published(self) -> None:
with TemporaryDirectory() as temporary:
base = Path(temporary)
gate2_path = base / "gate2.png"
gate2_path.write_bytes(base64.b64decode(_png_base64()))
raw = FakeRawDevice(back_timeout=True)
target = base / "gate3"
with self.assertRaises(FinalSubmitPanelError):
FinalSubmitPanelRunner(
FakeAdbClient(), lambda serial: raw, FakeForegroundReader(), timeout_seconds=0.1
).run(SERIAL, EXPECTED_GOODS_ID, _gate2(gate2_path), target)
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
self.assertEqual(methods.count("pressKey"), 1)
self.assertFalse(target.exists())
def test_version_drift_stops_before_screenshot_or_back(self) -> None:
with TemporaryDirectory() as temporary:
base = Path(temporary)
gate2_path = base / "gate2.png"
gate2_path.write_bytes(base64.b64decode(_png_base64()))
raw = FakeRawDevice(version="8.18.0")
with self.assertRaises(FinalSubmitPanelError):
FinalSubmitPanelRunner(
FakeAdbClient(), lambda serial: raw, FakeForegroundReader(), timeout_seconds=0.1
).run(SERIAL, EXPECTED_GOODS_ID, _gate2(gate2_path), base / "gate3")
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
self.assertNotIn("takeScreenshot", methods)
self.assertNotIn("pressKey", methods)
class FinalSubmitPanelAdapterTests(unittest.TestCase):
def test_adapter_rpc_surface_is_read_only_plus_one_back(self) -> None:
raw = FakeRawDevice()
adapter = UiautomatorFinalSubmitPanelAdapter(raw, FakeForegroundReader(), SERIAL, 0.1)
adapter.dump_window_hierarchy()
adapter.capture_screenshot()
adapter.leave_final_submit_panel_once()
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
self.assertEqual(methods, ["dumpWindowHierarchy", "takeScreenshot", "pressKey"])
self.assertEqual(adapter.page_control_action_attempts, 0)
with self.assertRaises(FinalSubmitPanelError):
adapter.leave_final_submit_panel_once()
self.assertEqual(methods.count("pressKey"), 1)
def test_adapter_seals_back_before_timeout(self) -> None:
raw = FakeRawDevice(back_timeout=True)
adapter = UiautomatorFinalSubmitPanelAdapter(raw, FakeForegroundReader(), SERIAL, 0.1)
with self.assertRaises(FinalSubmitPanelTimeoutError):
adapter.leave_final_submit_panel_once()
self.assertEqual(adapter.back_attempts, 1)
self.assertEqual(adapter.back_outcome, "ambiguous")
with self.assertRaises(FinalSubmitPanelError):
adapter.leave_final_submit_panel_once()
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
self.assertEqual(methods.count("pressKey"), 1)
@lru_cache(maxsize=1)
def _load_cli() -> object:
path = CLIENT_ROOT / "scripts" / "run_t107_final_submit_panel_dry_run.py"
spec = spec_from_file_location("run_t107_final_submit_panel_for_test", path)
if spec is None or spec.loader is None:
raise AssertionError("cannot load CLI")
module = module_from_spec(spec)
spec.loader.exec_module(module)
return module
class FinalSubmitPanelCliTests(unittest.TestCase):
def test_cli_has_no_submit_or_mode_switch(self) -> None:
module = _load_cli()
parser_args = module.parse_arguments(
[
"--serial", SERIAL,
"--goods-id", EXPECTED_GOODS_ID,
"--color", TASK_COLOR,
"--size", TASK_SIZE,
"--quantity", "2",
"--gate1-unit-price", "12.88",
"--gate2-panel-total-price", "32.76",
"--gate2-screenshot", "gate2.png",
"--gate2-captured-at", "2026-08-06T00:53:00+00:00",
"--max-total-price", "40.00",
"--output-dir", "gate3-output",
]
)
self.assertFalse(hasattr(parser_args, "allow_submit"))
self.assertFalse(hasattr(parser_args, "dry_run"))
def test_cli_failure_is_fixed_and_does_not_echo_sensitive_inputs(self) -> None:
module = _load_cli()
secret = "private-serial"
stream = StringIO()
with redirect_stderr(stream):
code = module.main(
[
"--serial", secret,
"--goods-id", "1",
"--color", TASK_COLOR,
"--size", TASK_SIZE,
"--quantity", "2",
"--gate1-unit-price", "12.88",
"--gate2-panel-total-price", "32.76",
"--gate2-screenshot", "missing.png",
"--gate2-captured-at", "2026-08-06T00:53:00+00:00",
"--max-total-price", "40.00",
"--output-dir", "gate3-output",
]
)
self.assertEqual(code, 2)
self.assertNotIn(secret, stream.getvalue())
class FinalSubmitPanelStaticBoundaryTests(unittest.TestCase):
def test_protocol_exposes_only_reads_and_named_single_back(self) -> None:
public = {name for name in FinalSubmitPanelDevice.__dict__ if not name.startswith("_")}
self.assertEqual(
public,
{
"app_info",
"current_foreground",
"display_size",
"dump_window_hierarchy",
"capture_screenshot",
"leave_final_submit_panel_once",
},
)
def test_observer_is_pure_and_accepts_no_device(self) -> None:
parameters = set(inspect.signature(observe_gate3).parameters)
self.assertEqual(parameters, {"raw_hierarchy", "gate2", "screenshot_path", "captured_at"})
source = (CLIENT_ROOT / "src/cmbuyer_client/pdd/final_submit_panel.py").read_text(encoding="utf-8")
self.assertNotIn("uiautomator", source)
self.assertNotIn("jsonrpc", source)
def test_production_rpc_literals_are_reads_plus_back_only(self) -> None:
path = CLIENT_ROOT / "src/cmbuyer_client/pdd/final_submit_panel_runner.py"
tree = ast.parse(path.read_text(encoding="utf-8"))
methods: set[str] = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Call) or len(node.args) < 2:
continue
if not isinstance(node.func, ast.Attribute) or node.func.attr != "_call":
continue
first, second = node.args[:2]
if isinstance(first, ast.Constant) and first.value == "jsonrpc_call" and isinstance(second, ast.Constant):
methods.add(second.value)
self.assertEqual(methods, {"dumpWindowHierarchy", "takeScreenshot", "pressKey"})
def test_imports_and_sources_have_no_forbidden_submission_capabilities(self) -> None:
paths = (
CLIENT_ROOT / "src/cmbuyer_client/pdd/final_submit_panel.py",
CLIENT_ROOT / "src/cmbuyer_client/pdd/final_submit_panel_runner.py",
CLIENT_ROOT / "scripts/run_t107_final_submit_panel_dry_run.py",
)
forbidden = (
"SubmissionPermit",
"submit_order_once",
"click_permitted",
"go_to_order_confirm",
"allow_submit",
"dry_run=False",
"submission_fence",
)
for path in paths:
source = path.read_text(encoding="utf-8")
tree = ast.parse(source)
imported = " ".join(
alias.name
for node in ast.walk(tree)
if isinstance(node, (ast.Import, ast.ImportFrom))
for alias in node.names
)
with self.subTest(path=path):
self.assertFalse(any(token.lower() in imported.lower() for token in ("fence", "payment", "result_sink")))
for token in forbidden:
self.assertNotIn(token, source)
def test_fixtures_contain_no_unrelated_personal_or_payment_data(self) -> None:
combined = PANEL_FIXTURE.read_text(encoding="utf-8") + EXIT_FIXTURE.read_text(encoding="utf-8")
for forbidden in ("手机号", "收货地址", "支付密码", "微信支付", "支付宝"):
self.assertNotIn(forbidden, combined)
if __name__ == "__main__":
unittest.main()