feat(client): add T-107 Gate3 dry-run observer
This commit is contained in:
@@ -0,0 +1,684 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user