685 lines
29 KiB
Python
685 lines
29 KiB
Python
"""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()
|