Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0b71d5a29 | ||
|
|
acc9f1d964 | ||
|
|
db2b781c8c | ||
|
|
3f09fbff66 | ||
|
|
21628a4dc5 | ||
|
|
1d726dd364 | ||
|
|
e18f7983fd | ||
|
|
ad80a7f23c | ||
|
|
994b6c8054 | ||
|
|
16b2487a2a | ||
|
|
67b427864a | ||
|
|
1bb54927e1 | ||
|
|
0afe9a53b3 | ||
|
|
63bd873c26 | ||
|
|
2e303f3829 | ||
|
|
f97dca95ae | ||
|
|
43fd8ce668 | ||
|
|
44a207df7f | ||
|
|
29fb528823 | ||
|
|
537159445e | ||
|
|
91071d4013 | ||
|
|
60cf05b08b | ||
|
|
fec21ae857 | ||
|
|
3984f399b0 | ||
|
|
251f3d92dd | ||
|
|
5fa8ac6ff2 | ||
|
|
a8570f59af | ||
|
|
ee56249014 | ||
|
|
e552e2e2ca | ||
|
|
e1e534c5d3 | ||
|
|
5a4633fb64 | ||
|
|
5d0fc81891 | ||
|
|
230cfdc697 | ||
|
|
b4ff9096c3 | ||
|
|
ae4b9ef08b | ||
|
|
1bf3acda70 | ||
|
|
e8ca738b33 | ||
|
|
429c2d33db | ||
|
|
a15b6c0911 | ||
|
|
29d1540f7c | ||
|
|
2f1380d084 | ||
|
|
81a44caeef | ||
|
|
757eca39fd | ||
|
|
b754cc9ca9 | ||
|
|
e45d26fe93 | ||
|
|
a09c58beef | ||
|
|
42eac57824 | ||
|
|
c9b39a4e05 | ||
|
|
870e5f861d | ||
|
|
025edaf273 | ||
|
|
584c5601c5 | ||
|
|
3a27225977 | ||
|
|
dfd88c3336 | ||
|
|
20112dbd61 | ||
|
|
404d0d3ca4 | ||
|
|
85e5f1e56e |
@@ -0,0 +1,112 @@
|
||||
"""采集 T-106 人工准备的确认页四态证据;不执行页面操作。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from math import isfinite
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
|
||||
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
|
||||
from cmbuyer_client.pdd.order_confirm_spike import (
|
||||
Android16ForegroundReader,
|
||||
DECLARED_STATES,
|
||||
EXPECTED_GOODS_ID,
|
||||
OrderConfirmEvidenceCapturer,
|
||||
OrderConfirmEvidenceError,
|
||||
)
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="采集 T-106 人工准备的确认页四态本机证据。")
|
||||
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||
parser.add_argument("--goods-id", required=True, help="T-106 已批准的目标商品标识。")
|
||||
parser.add_argument(
|
||||
"--state",
|
||||
required=True,
|
||||
choices=DECLARED_STATES,
|
||||
help=(
|
||||
"人工声明状态:Gate2 后导航来源、确认页 Gate3、最终控件可见、"
|
||||
"人工一次返回后的安全页。"
|
||||
),
|
||||
)
|
||||
parser.add_argument("--output-dir", required=True, type=Path, help="全新本机证据目录;不得覆盖。")
|
||||
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 与只读设备 RPC 超时(秒)。")
|
||||
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def validate_arguments(arguments: argparse.Namespace) -> None:
|
||||
if (
|
||||
type(arguments.serial) is not str
|
||||
or not arguments.serial.strip()
|
||||
or arguments.serial != arguments.serial.strip()
|
||||
):
|
||||
raise ValueError("必须显式提供非空 --serial。")
|
||||
if type(arguments.goods_id) is not str or arguments.goods_id != EXPECTED_GOODS_ID:
|
||||
raise ValueError("--goods-id 不是 T-106 已批准目标。")
|
||||
if type(arguments.state) is not str or arguments.state not in DECLARED_STATES:
|
||||
raise ValueError("--state 必须是批准的人工声明状态。")
|
||||
if not isinstance(arguments.output_dir, Path) or not arguments.output_dir.name:
|
||||
raise ValueError("--output-dir 必须是明确的全新目录。")
|
||||
if (
|
||||
not isinstance(arguments.timeout, (int, float))
|
||||
or isinstance(arguments.timeout, bool)
|
||||
or arguments.timeout <= 0
|
||||
or not isfinite(arguments.timeout)
|
||||
):
|
||||
raise ValueError("--timeout 必须是大于 0 的有限数值。")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
validate_arguments(arguments)
|
||||
except ValueError as error:
|
||||
print(f"失败:{error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
import adbutils
|
||||
import uiautomator2 as u2
|
||||
except ImportError:
|
||||
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
adb_runner = SubprocessAdbRunner(arguments.adb)
|
||||
capturer = OrderConfirmEvidenceCapturer(
|
||||
AdbClient(adb_runner, timeout_seconds=arguments.timeout),
|
||||
NoReconnectUiautomatorConnector(
|
||||
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
|
||||
u2.connect,
|
||||
),
|
||||
Android16ForegroundReader(adb_runner, arguments.timeout),
|
||||
timeout_seconds=arguments.timeout,
|
||||
)
|
||||
try:
|
||||
capturer.capture(
|
||||
arguments.serial,
|
||||
arguments.goods_id,
|
||||
arguments.state,
|
||||
arguments.output_dir,
|
||||
)
|
||||
except (DeviceConnectionError, OrderConfirmEvidenceError):
|
||||
# 第三方异常可能带设备、页面正文或本机目录,命令行只输出固定摘要。
|
||||
print("确认页四态只读取证失败:已停止,未发布本机证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
except OSError:
|
||||
print("确认页四态只读取证失败:无法发布本机证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("确认页四态只读取证完成。")
|
||||
print("人工复核:请在指定目录检查截图、XML、应用摘要和 manifest。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,120 @@
|
||||
"""采集 T-105 人工准备的数量 1/2 两态证据;不执行页面操作。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from math import isfinite
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
|
||||
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
|
||||
from cmbuyer_client.pdd.quantity_gate2_spike import (
|
||||
Android16TopResumedForegroundReader,
|
||||
DECLARED_QUANTITIES,
|
||||
EXPECTED_GOODS_ID,
|
||||
QuantityGate2EvidenceCapturer,
|
||||
QuantityGate2EvidenceError,
|
||||
)
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="采集 T-105 人工准备的数量两态本机证据。")
|
||||
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||
parser.add_argument("--goods-id", required=True, help="T-105 已批准的目标商品标识。")
|
||||
parser.add_argument(
|
||||
"--state",
|
||||
required=True,
|
||||
choices=sorted(DECLARED_QUANTITIES),
|
||||
help="人工声明状态:initial=数量1,target=数量2。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--declared-quantity",
|
||||
required=True,
|
||||
type=int,
|
||||
help="人工看到的数量;必须与状态一致。",
|
||||
)
|
||||
parser.add_argument("--output-dir", required=True, type=Path, help="全新本机证据目录;不得覆盖。")
|
||||
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 与只读设备 RPC 超时(秒)。")
|
||||
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def validate_arguments(arguments: argparse.Namespace) -> None:
|
||||
if (
|
||||
type(arguments.serial) is not str
|
||||
or not arguments.serial.strip()
|
||||
or arguments.serial != arguments.serial.strip()
|
||||
):
|
||||
raise ValueError("必须显式提供非空 --serial。")
|
||||
if type(arguments.goods_id) is not str or arguments.goods_id != EXPECTED_GOODS_ID:
|
||||
raise ValueError("--goods-id 不是 T-105 已批准目标。")
|
||||
if type(arguments.state) is not str or arguments.state not in DECLARED_QUANTITIES:
|
||||
raise ValueError("--state 必须是批准的人工声明状态。")
|
||||
if type(arguments.declared_quantity) is not int or (
|
||||
arguments.declared_quantity != DECLARED_QUANTITIES[arguments.state]
|
||||
):
|
||||
raise ValueError("--declared-quantity 必须与人工声明状态一致。")
|
||||
if not isinstance(arguments.output_dir, Path) or not arguments.output_dir.name:
|
||||
raise ValueError("--output-dir 必须是明确的全新目录。")
|
||||
if (
|
||||
not isinstance(arguments.timeout, (int, float))
|
||||
or isinstance(arguments.timeout, bool)
|
||||
or arguments.timeout <= 0
|
||||
or not isfinite(arguments.timeout)
|
||||
):
|
||||
raise ValueError("--timeout 必须是大于 0 的有限数值。")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
validate_arguments(arguments)
|
||||
except ValueError as error:
|
||||
print(f"失败:{error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
import adbutils
|
||||
import uiautomator2 as u2
|
||||
except ImportError:
|
||||
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
adb_runner = SubprocessAdbRunner(arguments.adb)
|
||||
capturer = QuantityGate2EvidenceCapturer(
|
||||
AdbClient(adb_runner, timeout_seconds=arguments.timeout),
|
||||
NoReconnectUiautomatorConnector(
|
||||
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
|
||||
u2.connect,
|
||||
),
|
||||
Android16TopResumedForegroundReader(adb_runner, arguments.timeout),
|
||||
timeout_seconds=arguments.timeout,
|
||||
)
|
||||
try:
|
||||
capturer.capture(
|
||||
arguments.serial,
|
||||
arguments.goods_id,
|
||||
arguments.state,
|
||||
arguments.declared_quantity,
|
||||
arguments.output_dir,
|
||||
)
|
||||
except (DeviceConnectionError, QuantityGate2EvidenceError):
|
||||
# 第三方异常可能含 serial、Activity、本机路径或页面正文,CLI 只输出固定摘要。
|
||||
print("数量两态只读取证失败:已停止,未发布本机证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
except OSError:
|
||||
print("数量两态只读取证失败:无法发布本机证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("数量两态只读取证完成。")
|
||||
print("人工复核:请在指定目录检查截图、XML、应用摘要和 manifest。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,85 @@
|
||||
"""采集 T-104 阶段 A 的一次 Back 后本机证据。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from math import isfinite
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
|
||||
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
|
||||
from cmbuyer_client.pdd.sku_selection import SkuSelectionError
|
||||
from cmbuyer_client.pdd.sku_selection_runner import (
|
||||
SkuExitSpikeCapturer,
|
||||
SkuSelectionRunError,
|
||||
)
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="采集 T-104 阶段 A 的一次 Back 后本机证据。")
|
||||
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||
parser.add_argument("--output-dir", required=True, type=Path, help="全新本机证据目录;不得覆盖。")
|
||||
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 与设备 RPC 超时(秒)。")
|
||||
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def validate_arguments(arguments: argparse.Namespace) -> None:
|
||||
if type(arguments.serial) is not str or not arguments.serial.strip():
|
||||
raise ValueError("必须显式提供非空 --serial。")
|
||||
if not isinstance(arguments.output_dir, Path) or not arguments.output_dir.name:
|
||||
raise ValueError("--output-dir 必须是明确的全新目录。")
|
||||
if (
|
||||
not isinstance(arguments.timeout, (int, float))
|
||||
or isinstance(arguments.timeout, bool)
|
||||
or arguments.timeout <= 0
|
||||
or not isfinite(arguments.timeout)
|
||||
):
|
||||
raise ValueError("--timeout 必须是大于 0 的有限数值。")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
validate_arguments(arguments)
|
||||
except ValueError as error:
|
||||
print(f"失败:{error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
import adbutils
|
||||
import uiautomator2 as u2
|
||||
except ImportError:
|
||||
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
capturer = SkuExitSpikeCapturer(
|
||||
AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout),
|
||||
NoReconnectUiautomatorConnector(
|
||||
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
|
||||
u2.connect,
|
||||
),
|
||||
timeout_seconds=arguments.timeout,
|
||||
)
|
||||
try:
|
||||
capturer.capture(arguments.serial, arguments.output_dir)
|
||||
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError):
|
||||
# 不回显第三方异常、serial、页面正文或本机路径。
|
||||
print("规格安全退出取证失败:已停止,未发布本地证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
except OSError:
|
||||
print("规格安全退出取证失败:无法发布本地证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("规格安全退出取证完成。")
|
||||
print("人工复核:请在指定目录检查退出后截图、XML、应用摘要和 manifest。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,108 @@
|
||||
"""采集 T-103 一次性尺码 reveal 的 before/after 本机证据。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from math import isfinite
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
|
||||
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
|
||||
from cmbuyer_client.pdd.sku_reveal_spike import (
|
||||
safe_reveal_failure_stage,
|
||||
SkuRevealSpikeCapturer,
|
||||
SkuRevealSpikeError,
|
||||
)
|
||||
from cmbuyer_client.pdd.sku_selection import (
|
||||
EXPECTED_GOODS_ID,
|
||||
_safe_sku_entry_failure_stage,
|
||||
SkuSelectionError,
|
||||
)
|
||||
from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="采集 T-103 一次性尺码 reveal 的本机证据。")
|
||||
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||
parser.add_argument("--goods-id", required=True, help="固定 T-103 已取证 goods_id。")
|
||||
parser.add_argument("--output-dir", required=True, type=Path, help="全新本机证据目录;不得覆盖。")
|
||||
parser.add_argument("--timeout", type=float, default=30.0, help="整趟动作与只读调和时限(秒)。")
|
||||
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def validate_arguments(arguments: argparse.Namespace) -> None:
|
||||
if type(arguments.serial) is not str or not arguments.serial.strip():
|
||||
raise ValueError("必须显式提供非空 --serial。")
|
||||
if type(arguments.goods_id) is not str or arguments.goods_id != EXPECTED_GOODS_ID:
|
||||
raise ValueError("--goods-id 不是 T-103 已取证商品。")
|
||||
if (
|
||||
not isinstance(arguments.timeout, (int, float))
|
||||
or isinstance(arguments.timeout, bool)
|
||||
or arguments.timeout <= 0
|
||||
or not isfinite(arguments.timeout)
|
||||
):
|
||||
raise ValueError("--timeout 必须是大于 0 的有限数值。")
|
||||
|
||||
|
||||
def _safe_capture_failure_stage(error: BaseException) -> str:
|
||||
# 入口与 reveal 各有不可伪造的正式 marker;入口优先,不能被外层 reveal 标注覆盖。
|
||||
entry_stage = _safe_sku_entry_failure_stage(error)
|
||||
if entry_stage is not None:
|
||||
return entry_stage
|
||||
return safe_reveal_failure_stage(error) or "unknown"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
validate_arguments(arguments)
|
||||
except ValueError as error:
|
||||
print(f"失败:{error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
import adbutils
|
||||
import uiautomator2 as u2
|
||||
except ImportError:
|
||||
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
capturer = SkuRevealSpikeCapturer(
|
||||
AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout),
|
||||
NoReconnectUiautomatorConnector(
|
||||
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
|
||||
u2.connect,
|
||||
),
|
||||
timeout_seconds=arguments.timeout,
|
||||
)
|
||||
try:
|
||||
result = capturer.capture(
|
||||
arguments.serial,
|
||||
arguments.goods_id,
|
||||
arguments.output_dir,
|
||||
)
|
||||
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError, SkuRevealSpikeError) as error:
|
||||
# 不回显页面正文、节点、serial、坐标、路径或第三方异常。
|
||||
print(
|
||||
f"规格 reveal 取证失败:stage={_safe_capture_failure_stage(error)};已停止,未发布本地证据目录。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
except OSError:
|
||||
print("规格 reveal 取证失败:无法创建或发布本地证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"规格 reveal 取证完成:{result.output_directory}")
|
||||
print(f"manifest:{result.manifest_path}")
|
||||
print("人工复核:请保持手机不动,确认颜色仍选中、S/M 均未选且未进入提交页。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -15,7 +15,7 @@ from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, Subproce
|
||||
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
|
||||
from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
|
||||
from cmbuyer_client.pdd.sku_selection import EXPECTED_GOODS_ID, SkuSelectionError, TASK_TO_UI_SELECTION
|
||||
from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError, SkuSelectionRunner
|
||||
from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError, SkuSelectionRunner, safe_failure_stage
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
@@ -66,7 +66,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||
result = runner.run(arguments.serial, arguments.url, arguments.color, arguments.size, arguments.output_dir)
|
||||
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError) as error:
|
||||
# Flow 可能来自测试替身或未来实现;CLI 不回显任何异常正文,避免泄露节点树或页面文本。
|
||||
print("规格恢复失败:已停止,未发布本地证据目录。", file=sys.stderr)
|
||||
print(
|
||||
f"规格恢复失败:stage={safe_failure_stage(error)};已停止,未发布本地证据目录。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
except OSError:
|
||||
print("规格恢复失败:无法创建或发布本地证据目录。", file=sys.stderr)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""运行 T-105 数量 1→2、Gate2 截图与一次安全退出。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
from math import isfinite
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
|
||||
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
|
||||
from cmbuyer_client.pdd.quantity_gate2 import (
|
||||
EXPECTED_GATE1_UNIT_PRICE,
|
||||
EXPECTED_GOODS_ID,
|
||||
TASK_COLOR,
|
||||
TASK_SIZE,
|
||||
Gate1Observation,
|
||||
QuantityGate2Error,
|
||||
)
|
||||
from cmbuyer_client.pdd.quantity_gate2_runner import QuantityGate2Runner
|
||||
from cmbuyer_client.pdd.quantity_gate2_spike import Android16TopResumedForegroundReader
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="运行 T-105 已取证数量与 Gate2 闭环。")
|
||||
parser.add_argument("--serial", required=True, help="显式 ADB serial;禁止自动选择。")
|
||||
parser.add_argument("--goods-id", required=True)
|
||||
parser.add_argument("--color", required=True)
|
||||
parser.add_argument("--size", required=True)
|
||||
parser.add_argument("--target-quantity", required=True, type=int)
|
||||
parser.add_argument("--gate1-unit-price", required=True)
|
||||
parser.add_argument("--gate1-screenshot", required=True, type=Path)
|
||||
parser.add_argument("--gate1-captured-at", required=True)
|
||||
parser.add_argument("--max-total-price", required=True)
|
||||
parser.add_argument("--output-dir", required=True, type=Path)
|
||||
parser.add_argument("--timeout", type=float, default=10.0)
|
||||
parser.add_argument("--adb", default="adb")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def validate_arguments(arguments: argparse.Namespace) -> datetime:
|
||||
if type(arguments.serial) is not str or not arguments.serial.strip() or arguments.serial != arguments.serial.strip():
|
||||
raise ValueError("必须显式提供非空 --serial。")
|
||||
if arguments.goods_id != EXPECTED_GOODS_ID:
|
||||
raise ValueError("--goods-id 不是 T-105 已批准目标。")
|
||||
if arguments.color != TASK_COLOR or arguments.size != TASK_SIZE:
|
||||
raise ValueError("颜色或尺码不是 T-105 已批准目标。")
|
||||
if type(arguments.target_quantity) is not int or arguments.target_quantity != 2:
|
||||
raise ValueError("本次真机验收只批准 --target-quantity 2。")
|
||||
if arguments.gate1_unit_price != EXPECTED_GATE1_UNIT_PRICE:
|
||||
raise ValueError("--gate1-unit-price 与已确认 Gate1 不一致。")
|
||||
if not isinstance(arguments.gate1_screenshot, Path) or not arguments.gate1_screenshot.is_file():
|
||||
raise ValueError("--gate1-screenshot 必须是现有显式文件。")
|
||||
try:
|
||||
captured_at = datetime.fromisoformat(arguments.gate1_captured_at)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError("--gate1-captured-at 必须是带时区 ISO 时间。") from error
|
||||
if captured_at.utcoffset() is None:
|
||||
raise ValueError("--gate1-captured-at 必须带时区。")
|
||||
if arguments.max_total_price != "40.00":
|
||||
raise ValueError("本次真机验收固定 --max-total-price 40.00。")
|
||||
if not isinstance(arguments.output_dir, Path) or not arguments.output_dir.name or arguments.output_dir.exists():
|
||||
raise ValueError("--output-dir 必须是不存在的明确新目录。")
|
||||
if (
|
||||
not isinstance(arguments.timeout, (int, float))
|
||||
or isinstance(arguments.timeout, bool)
|
||||
or arguments.timeout <= 0
|
||||
or not isfinite(arguments.timeout)
|
||||
):
|
||||
raise ValueError("--timeout 必须是大于 0 的有限数值。")
|
||||
return captured_at
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
captured_at = validate_arguments(arguments)
|
||||
except ValueError as error:
|
||||
print(f"失败:{error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
import adbutils
|
||||
import uiautomator2 as u2
|
||||
except ImportError:
|
||||
print("失败:缺少采购工具真机依赖。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
adb_runner = SubprocessAdbRunner(arguments.adb)
|
||||
runner = QuantityGate2Runner(
|
||||
AdbClient(adb_runner, timeout_seconds=arguments.timeout),
|
||||
NoReconnectUiautomatorConnector(
|
||||
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
|
||||
u2.connect,
|
||||
),
|
||||
Android16TopResumedForegroundReader(adb_runner, arguments.timeout),
|
||||
timeout_seconds=arguments.timeout,
|
||||
)
|
||||
gate1 = Gate1Observation(
|
||||
color=arguments.color,
|
||||
size=arguments.size,
|
||||
quantity=1,
|
||||
gate1_unit_price=arguments.gate1_unit_price,
|
||||
screenshot_path=arguments.gate1_screenshot,
|
||||
captured_at=captured_at,
|
||||
)
|
||||
try:
|
||||
runner.run(
|
||||
arguments.serial,
|
||||
arguments.goods_id,
|
||||
gate1,
|
||||
arguments.target_quantity,
|
||||
arguments.max_total_price,
|
||||
arguments.output_dir,
|
||||
)
|
||||
except (DeviceConnectionError, QuantityGate2Error, OSError):
|
||||
# 不回显第三方异常、serial、本机路径或页面正文。
|
||||
print("T-105 数量/Gate2 运行失败:已停止,未发布证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("T-105 数量/Gate2 运行完成。")
|
||||
print("人工复核:数量 2、目标规格、12.88/32.76、原始截图和一次安全退出。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -5,8 +5,9 @@ from __future__ import annotations
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
|
||||
from .core.errors import StateError
|
||||
from .logging_policy import configure_application_logger
|
||||
from .runtime import RuntimePaths
|
||||
from .runtime import LocalStateRuntime, RuntimePaths
|
||||
|
||||
|
||||
def select_application_argv(argv: Sequence[str] | None) -> list[str]:
|
||||
@@ -30,8 +31,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
return 1
|
||||
|
||||
try:
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QMainWindow
|
||||
from PySide6.QtWidgets import QApplication
|
||||
except ImportError:
|
||||
logger.error("缺少 PySide6,无法启动桌面界面。")
|
||||
print("无法启动采购工具:缺少 PySide6。请先安装 requirements.txt 中的依赖。", file=sys.stderr)
|
||||
@@ -39,19 +39,47 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
|
||||
application = QApplication.instance() or QApplication(select_application_argv(argv))
|
||||
application.setApplicationName("采购工具")
|
||||
runtime: LocalStateRuntime | None = None
|
||||
coordinator = None
|
||||
try:
|
||||
runtime = LocalStateRuntime.open(paths)
|
||||
try:
|
||||
summary = runtime.store.load_profile_summary("default")
|
||||
except StateError as error:
|
||||
if error.reason != "profile_not_found":
|
||||
raise
|
||||
summary = None
|
||||
|
||||
window = QMainWindow()
|
||||
window.setWindowTitle("采购工具")
|
||||
window.setAccessibleName("采购工具")
|
||||
window.setMinimumSize(420, 240)
|
||||
window.resize(560, 320)
|
||||
from .polling.coordinator import PollingCoordinator
|
||||
from .ui.main_window import PurchaseToolWindow
|
||||
|
||||
message = QLabel("应用骨架已初始化。\n采购执行功能尚未启用。")
|
||||
message.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
message.setWordWrap(True)
|
||||
message.setAccessibleName("当前状态")
|
||||
window.setCentralWidget(message)
|
||||
|
||||
logger.info("应用已启动;采购执行功能尚未启用。")
|
||||
window.show()
|
||||
return application.exec()
|
||||
settings = None if summary is None else summary.settings
|
||||
has_token = False if summary is None else summary.has_stored_device_token
|
||||
coordinator = PollingCoordinator(
|
||||
profile_id="default",
|
||||
store=runtime.store,
|
||||
gateway_factory=None,
|
||||
consumer=None,
|
||||
profile_settings=settings,
|
||||
poll_interval_seconds=15 if settings is None else settings.poll_interval_seconds,
|
||||
failure_threshold=3 if settings is None else settings.failure_threshold,
|
||||
)
|
||||
window = PurchaseToolWindow(
|
||||
store=runtime.store,
|
||||
coordinator=coordinator,
|
||||
profile_settings=settings,
|
||||
has_stored_device_token=has_token,
|
||||
)
|
||||
logger.info("应用已启动;单趟执行能力尚未接入,真实领取保持禁用。")
|
||||
window.show()
|
||||
return application.exec()
|
||||
except (OSError, RuntimeError, StateError):
|
||||
logger.error("无法打开采购工具本地安全状态。")
|
||||
print("无法启动采购工具:本地安全状态不可用。", file=sys.stderr)
|
||||
return 3
|
||||
finally:
|
||||
worker_stopped = True
|
||||
if coordinator is not None:
|
||||
worker_stopped = coordinator.shutdown()
|
||||
if runtime is not None and worker_stopped:
|
||||
runtime.close()
|
||||
|
||||
@@ -51,6 +51,18 @@ class LoadedProfile:
|
||||
return f"LoadedProfile(settings={self.settings!r}, credentials=[已隐藏])"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProfileSummary:
|
||||
"""不解密、不返回任何 token 数据的配置页只读摘要。"""
|
||||
|
||||
settings: ProfileSettings
|
||||
has_stored_device_token: bool
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if type(self.has_stored_device_token) is not bool:
|
||||
raise ValueError("invalid_token_presence")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PollingSession:
|
||||
profile_id: str
|
||||
|
||||
@@ -31,7 +31,7 @@ from cmbuyer_client.core.models import (
|
||||
)
|
||||
from cmbuyer_client.core.validation import datetime_nanoseconds, require_rfc3339_z, require_uuid4, rfc3339_z_nanoseconds
|
||||
|
||||
from .models import LoadedProfile, PendingEvidence, PollingSession, ProfileSettings, RecoverySnapshot
|
||||
from .models import LoadedProfile, PendingEvidence, PollingSession, ProfileSettings, ProfileSummary, RecoverySnapshot
|
||||
from .protection import SecretProtector
|
||||
|
||||
|
||||
@@ -346,6 +346,32 @@ class LocalStateStore:
|
||||
token = self._unprotect_token(bytes(row[2]), purpose=_device_token_purpose(profile_id, settings.device_id))
|
||||
return LoadedProfile(settings, DeviceCredentials(settings.device_id, token))
|
||||
|
||||
def load_profile_summary(self, profile_id: str) -> ProfileSummary:
|
||||
"""读取非秘密设置与 token 存在性,绝不经过 SecretProtector。"""
|
||||
|
||||
with self._read_transaction() as connection:
|
||||
row = connection.execute(
|
||||
"""SELECT service_url,device_id,adb_path,adb_serial,transport,
|
||||
poll_interval_seconds,failure_threshold,http_timeout_seconds,step_timeout_seconds,
|
||||
typeof(device_token_cipher),length(device_token_cipher)
|
||||
FROM profiles WHERE profile_id=?""",
|
||||
(profile_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise StateError("profile_not_found")
|
||||
# summary 不解密,但也不能把损坏的密文降级成“尚未保存”。否则 UI 会
|
||||
# 允许覆盖本应进入人工恢复的本地状态。DPAPI 密文长度不固定,只要求
|
||||
# SQLite storage class 确为 BLOB 且非空。
|
||||
if row[9] != "blob" or type(row[10]) is not int or row[10] <= 0:
|
||||
raise StateError("invalid_device_token_cipher")
|
||||
try:
|
||||
settings = ProfileSettings(profile_id, *row[:9])
|
||||
except (TypeError, ValueError, ValidationError):
|
||||
# 存储字段损坏不得把裸模型异常或实际值带到 UI;也不得以默认配置
|
||||
# 继续启动。配置修复必须显式进行。
|
||||
raise StateError("stored_profile_invalid") from None
|
||||
return ProfileSummary(settings, True)
|
||||
|
||||
def start_or_resume_polling(self, profile_id: str) -> PollingSession:
|
||||
now = self._utc_now()
|
||||
with self._transaction() as connection:
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
"""T-106 确认页四态的纯只读取证;页面对应性只由人工确认。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from math import isfinite
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from adbutils.errors import AdbTimeout
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from uiautomator2.exceptions import HTTPTimeoutError
|
||||
|
||||
from ..device.adb import AdbClient, CommandRunner, DeviceConnectionError, DeviceInspection
|
||||
from ..device.baseline import (
|
||||
HIERARCHY_PARAMS,
|
||||
PDD_PACKAGE,
|
||||
SCREENSHOT_PARAMS,
|
||||
_save_base64_screenshot,
|
||||
_sha256_file,
|
||||
_validate_hierarchy,
|
||||
)
|
||||
from .product_open import EXPECTED_PDD_VERSION
|
||||
from .product_url import parse_product_url
|
||||
|
||||
|
||||
EXPECTED_GOODS_ID = "937122477375"
|
||||
EXPECTED_DEVICE_MODEL = "PKG110"
|
||||
EXPECTED_ANDROID_VERSION = "16"
|
||||
EXPECTED_SCREEN_SIZE = (1080, 2376)
|
||||
DECLARED_STATES = (
|
||||
"gate2-navigation-source",
|
||||
"confirm-gate3",
|
||||
"submit-control-visible",
|
||||
"returned-safe-page",
|
||||
)
|
||||
|
||||
|
||||
class OrderConfirmEvidenceError(RuntimeError):
|
||||
"""T-106 四态证据未形成完整原子产物。"""
|
||||
|
||||
|
||||
class OrderConfirmEvidenceTimeoutError(OrderConfirmEvidenceError):
|
||||
"""只读取证设备调用超时。"""
|
||||
|
||||
|
||||
class OrderConfirmReadDevice(Protocol):
|
||||
"""T-106 唯一设备边界;故意只有读取能力。"""
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]: ...
|
||||
|
||||
def window_size(self) -> tuple[int, int]: ...
|
||||
|
||||
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any: ...
|
||||
|
||||
|
||||
class OrderConfirmForegroundReader(Protocol):
|
||||
"""读取 Android 前台摘要,不暴露通用命令执行。"""
|
||||
|
||||
def read(self, serial: str) -> dict[str, str]: ...
|
||||
|
||||
|
||||
class Android16ForegroundReader:
|
||||
"""读取 Android 16 的唯一 top-resumed Activity。"""
|
||||
|
||||
_TOP_RESUMED_PATTERN = re.compile(
|
||||
r"(?m)^\s*topResumedActivity=ActivityRecord\{[^\r\n}]*?\s+u\d+\s+"
|
||||
r"(?P<package>[^/\s]+)/(?P<activity>[^\s}]+)\s+t\d+\}\s*$"
|
||||
)
|
||||
|
||||
def __init__(self, runner: CommandRunner, timeout_seconds: float) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._runner = runner
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
def read(self, serial: str) -> dict[str, str]:
|
||||
if type(serial) is not str or not serial.strip() or serial != serial.strip():
|
||||
raise OrderConfirmEvidenceError("必须显式提供非空设备通道。")
|
||||
result = self._runner.run(
|
||||
("-s", serial, "shell", "dumpsys", "activity", "activities"),
|
||||
self._timeout_seconds,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise OrderConfirmEvidenceError("Android 前台摘要读取失败,未发布证据。")
|
||||
matches = list(self._TOP_RESUMED_PATTERN.finditer(result.stdout))
|
||||
if len(matches) != 1:
|
||||
raise OrderConfirmEvidenceError("Android 前台摘要不唯一,未发布证据。")
|
||||
match = matches[0]
|
||||
return {"package": match.group("package"), "activity": match.group("activity")}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OrderConfirmEvidenceResult:
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
screenshot_path: Path
|
||||
hierarchy_path: Path
|
||||
app_path: Path
|
||||
|
||||
|
||||
class OrderConfirmEvidenceCapturer:
|
||||
"""采集人工准备的单个稳定状态,不解析页面业务字段。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adb_client: AdbClient,
|
||||
connector: Callable[[str], OrderConfirmReadDevice],
|
||||
foreground_reader: OrderConfirmForegroundReader,
|
||||
timeout_seconds: float,
|
||||
) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._adb_client = adb_client
|
||||
self._connector = connector
|
||||
self._foreground_reader = foreground_reader
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._started = False
|
||||
|
||||
def capture(
|
||||
self,
|
||||
serial: str,
|
||||
goods_id: str,
|
||||
human_declared_state: str,
|
||||
output_directory: Path,
|
||||
) -> OrderConfirmEvidenceResult:
|
||||
if self._started:
|
||||
raise OrderConfirmEvidenceError("同一取证器不可重复调用。")
|
||||
self._started = True
|
||||
_validate_inputs(serial, goods_id, human_declared_state)
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
|
||||
staging: Path | None = None
|
||||
try:
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
_require_expected_device(inspection)
|
||||
device = self._connector(serial)
|
||||
initial_app = _require_read_precondition(
|
||||
device,
|
||||
self._foreground_reader.read(serial),
|
||||
)
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
||||
staging.mkdir()
|
||||
|
||||
screenshot_path = staging / "screenshot.png"
|
||||
screenshot_payload = _read_rpc(
|
||||
device,
|
||||
"takeScreenshot",
|
||||
SCREENSHOT_PARAMS,
|
||||
self._timeout_seconds,
|
||||
)
|
||||
if not isinstance(screenshot_payload, str):
|
||||
raise OrderConfirmEvidenceError("页面截图无效,未发布证据。")
|
||||
_save_base64_screenshot(screenshot_payload, screenshot_path)
|
||||
_require_screenshot(screenshot_path)
|
||||
|
||||
hierarchy = _read_rpc(
|
||||
device,
|
||||
"dumpWindowHierarchy",
|
||||
HIERARCHY_PARAMS,
|
||||
self._timeout_seconds,
|
||||
)
|
||||
_validate_hierarchy(hierarchy)
|
||||
hierarchy_path = staging / "hierarchy.xml"
|
||||
hierarchy_path.write_text(hierarchy, encoding="utf-8")
|
||||
|
||||
final_app = _require_read_precondition(
|
||||
device,
|
||||
self._foreground_reader.read(serial),
|
||||
)
|
||||
if final_app != initial_app:
|
||||
raise OrderConfirmEvidenceError("取证期间前台页面漂移,未发布证据。")
|
||||
|
||||
app_path = staging / "app.json"
|
||||
app_path.write_text(
|
||||
json.dumps(final_app, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
manifest_path = staging / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
_manifest(
|
||||
inspection,
|
||||
serial,
|
||||
goods_id,
|
||||
human_declared_state,
|
||||
screenshot_path,
|
||||
hierarchy_path,
|
||||
app_path,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.rename(staging, target)
|
||||
staging = None
|
||||
except (DeviceConnectionError, OrderConfirmEvidenceError):
|
||||
_clean_staging(staging)
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
_clean_staging(staging)
|
||||
raise OrderConfirmEvidenceTimeoutError("确认页四态只读取证超时,未发布证据。") from error
|
||||
except (OSError, UnidentifiedImageError, ValueError) as error:
|
||||
_clean_staging(staging)
|
||||
raise OrderConfirmEvidenceError("确认页四态证据无法原子发布,未发布证据。") from error
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
raise OrderConfirmEvidenceError("确认页四态只读取证未完成,未发布证据。") from error
|
||||
|
||||
return OrderConfirmEvidenceResult(
|
||||
output_directory=target,
|
||||
manifest_path=target / "manifest.json",
|
||||
screenshot_path=target / "screenshot.png",
|
||||
hierarchy_path=target / "hierarchy.xml",
|
||||
app_path=target / "app.json",
|
||||
)
|
||||
|
||||
|
||||
def _is_positive_finite(value: object) -> bool:
|
||||
return (
|
||||
isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and value > 0
|
||||
and isfinite(value)
|
||||
)
|
||||
|
||||
|
||||
def _validate_inputs(serial: object, goods_id: object, state: object) -> None:
|
||||
if type(serial) is not str or not serial.strip() or serial != serial.strip():
|
||||
raise OrderConfirmEvidenceError("必须显式提供非空设备通道。")
|
||||
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
|
||||
raise OrderConfirmEvidenceError("商品不是 T-106 已批准取证目标。")
|
||||
parse_product_url(f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}")
|
||||
if type(state) is not str or state not in DECLARED_STATES:
|
||||
raise OrderConfirmEvidenceError("人工声明状态无效。")
|
||||
|
||||
|
||||
def _validate_new_target(target: Path) -> None:
|
||||
if target.exists() or not target.name:
|
||||
raise OrderConfirmEvidenceError("输出目录必须是不存在的明确新目录。")
|
||||
|
||||
|
||||
def _require_expected_device(inspection: DeviceInspection) -> None:
|
||||
if inspection.model != EXPECTED_DEVICE_MODEL or inspection.android_version != EXPECTED_ANDROID_VERSION:
|
||||
raise OrderConfirmEvidenceError("设备不是已批准取证组合。")
|
||||
|
||||
|
||||
def _require_read_precondition(
|
||||
device: OrderConfirmReadDevice,
|
||||
current: object,
|
||||
) -> dict[str, str]:
|
||||
info = device.app_info(PDD_PACKAGE)
|
||||
version = (info.get("versionName") or info.get("version_name")) if isinstance(info, dict) else None
|
||||
if version != EXPECTED_PDD_VERSION:
|
||||
raise OrderConfirmEvidenceError("拼多多版本不是已批准取证版本。")
|
||||
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
|
||||
raise OrderConfirmEvidenceError("拼多多不在前台。")
|
||||
activity = current.get("activity")
|
||||
if not isinstance(activity, str) or not activity.strip():
|
||||
raise OrderConfirmEvidenceError("前台应用摘要不完整。")
|
||||
if device.window_size() != EXPECTED_SCREEN_SIZE:
|
||||
raise OrderConfirmEvidenceError("屏幕坐标空间不是已批准尺寸。")
|
||||
return {"package": PDD_PACKAGE, "activity": activity, "pdd_version": EXPECTED_PDD_VERSION}
|
||||
|
||||
|
||||
def _read_rpc(
|
||||
device: OrderConfirmReadDevice,
|
||||
method: str,
|
||||
params: object,
|
||||
timeout_seconds: float,
|
||||
) -> object:
|
||||
return device.jsonrpc_call(method, params, timeout=timeout_seconds)
|
||||
|
||||
|
||||
def _require_screenshot(path: Path) -> None:
|
||||
with Image.open(path) as image:
|
||||
image.load()
|
||||
if image.size != EXPECTED_SCREEN_SIZE or image.format != "PNG":
|
||||
raise OrderConfirmEvidenceError("页面截图格式或尺寸无效。")
|
||||
|
||||
|
||||
def _clean_staging(staging: Path | None) -> None:
|
||||
if staging is not None and staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
|
||||
|
||||
def _manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
goods_id: str,
|
||||
state: str,
|
||||
screenshot_path: Path,
|
||||
hierarchy_path: Path,
|
||||
app_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"operation": "t106-order-confirm-readonly-evidence",
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"product": {
|
||||
"goods_id": goods_id,
|
||||
"canonical_url": f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}",
|
||||
},
|
||||
"human_declared_state": state,
|
||||
"review_status": "human_review_required",
|
||||
"channel": "wifi" if ":" in serial else "usb",
|
||||
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||
"device": {
|
||||
"model": inspection.model,
|
||||
"android_version": inspection.android_version,
|
||||
"pdd_package": PDD_PACKAGE,
|
||||
"pdd_version": EXPECTED_PDD_VERSION,
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
"path": screenshot_path.name,
|
||||
"role": "human_prepared_state_raw_screenshot",
|
||||
"sha256": _sha256_file(screenshot_path),
|
||||
},
|
||||
{
|
||||
"path": hierarchy_path.name,
|
||||
"role": "human_prepared_state_raw_hierarchy_local_only",
|
||||
"sha256": _sha256_file(hierarchy_path),
|
||||
},
|
||||
{
|
||||
"path": app_path.name,
|
||||
"role": "human_prepared_state_app_identity",
|
||||
"sha256": _sha256_file(app_path),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
"""T-105:证据绑定的数量读回与 Gate2 面板总额。
|
||||
|
||||
本模块只允许数量 1 保持不变,或从数量 1 对唯一加号点击一次到数量 2。
|
||||
它不包含确认页、提交围栏、提交订单或付款能力。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from functools import partial
|
||||
from math import isfinite
|
||||
from pathlib import Path
|
||||
import re
|
||||
from time import monotonic, sleep
|
||||
from typing import Any, Callable, Protocol
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from ..core.errors import ValidationError
|
||||
from ..core.validation import require_money
|
||||
from ..device.baseline import PDD_PACKAGE
|
||||
from .product_open import EXPECTED_PDD_VERSION
|
||||
from .sku_selection import SkuSelectionError, _parse_nodes as _parse_sku_nodes
|
||||
from .sku_selection import _product_exit_projection
|
||||
|
||||
|
||||
EXPECTED_DEVICE_MODEL = "PKG110"
|
||||
EXPECTED_ANDROID_VERSION = "16"
|
||||
EXPECTED_SCREEN_SIZE = (1080, 2376)
|
||||
EXPECTED_GOODS_ID = "937122477375"
|
||||
TASK_COLOR = "黑色CHA(纯棉)"
|
||||
TASK_SIZE = "M(建议100-115)"
|
||||
UI_COLOR = "黑色 CHA (纯棉)"
|
||||
UI_SIZE = "M(建议100-115)"
|
||||
EXPECTED_GATE1_UNIT_PRICE = "12.88"
|
||||
|
||||
_PDD_ID = "com.xunmeng.pinduoduo:id/pdd"
|
||||
_QUANTITY_CONTAINER_ID = "com.xunmeng.pinduoduo:id/gnl"
|
||||
_COLOR_ID = "com.xunmeng.pinduoduo:id/tv_content"
|
||||
_TARGET_SUMMARY = f"已选: {UI_COLOR} {UI_SIZE}"
|
||||
_AMOUNT_TEXT = re.compile(r"^快卖完 ¥(?P<amount>(?:0|[1-9]\d*)\.\d{2})$")
|
||||
_BOUNDS = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$")
|
||||
|
||||
|
||||
class QuantityGate2Error(RuntimeError):
|
||||
"""数量/Gate2 判据不成立时的脱敏安全停止。"""
|
||||
|
||||
|
||||
class QuantityGate2TimeoutError(QuantityGate2Error):
|
||||
"""设备调用或后置条件等待超时。"""
|
||||
|
||||
|
||||
class QuantityGate2OverCapError(QuantityGate2Error):
|
||||
"""目标数量面板总额超过管理员授权上限。"""
|
||||
|
||||
|
||||
class QuantityGate2Device(Protocol):
|
||||
"""T-105 的窄设备能力;没有通用页面动作。"""
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]: ...
|
||||
|
||||
def current_foreground(self) -> dict[str, str]: ...
|
||||
|
||||
def display_size(self) -> tuple[int, int]: ...
|
||||
|
||||
def dump_window_hierarchy(self) -> str: ...
|
||||
|
||||
def increment_quantity_once(self, bounds: str) -> None: ...
|
||||
|
||||
def capture_screenshot(self) -> str: ...
|
||||
|
||||
def leave_sku_panel_once(self) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Gate1Observation:
|
||||
color: str
|
||||
size: str
|
||||
quantity: int
|
||||
gate1_unit_price: str
|
||||
screenshot_path: Path
|
||||
captured_at: datetime
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.color != TASK_COLOR or self.size != TASK_SIZE or type(self.quantity) is not int or self.quantity != 1:
|
||||
raise QuantityGate2Error("Gate1 规格或数量不是已取证前置,已停止操作。")
|
||||
if _money(self.gate1_unit_price) != EXPECTED_GATE1_UNIT_PRICE:
|
||||
raise QuantityGate2Error("Gate1 单价不是已取证值,已停止操作。")
|
||||
if not isinstance(self.screenshot_path, Path) or not self.screenshot_path.name:
|
||||
raise QuantityGate2Error("Gate1 截图路径无效,已停止操作。")
|
||||
if not isinstance(self.captured_at, datetime) or self.captured_at.utcoffset() is None:
|
||||
raise QuantityGate2Error("Gate1 采集时间必须带时区,已停止操作。")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Gate2Observation:
|
||||
requested_color: str
|
||||
requested_size: str
|
||||
actual_color: str
|
||||
actual_size: str
|
||||
requested_quantity: int
|
||||
quantity_read: int
|
||||
gate1_unit_price: str
|
||||
gate2_panel_total_price: str
|
||||
max_total_price: str
|
||||
screenshot_path: Path
|
||||
captured_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PanelProfile:
|
||||
quantity: int
|
||||
panel_bounds: str
|
||||
price_row_bounds: str
|
||||
price_bounds: str
|
||||
price_text: str
|
||||
summary_bounds: str
|
||||
quantity_bounds: str
|
||||
minus_bounds: str
|
||||
value_bounds: str
|
||||
plus_bounds: str
|
||||
color_bounds: str
|
||||
|
||||
|
||||
_INITIAL = _PanelProfile(
|
||||
1,
|
||||
"[0,474][1080,863]",
|
||||
"[396,498][895,570]",
|
||||
"[396,503][712,570]",
|
||||
"快卖完 ¥12.88",
|
||||
"[396,654][1053,716]",
|
||||
"[396,752][645,827]",
|
||||
"[396,752][474,827]",
|
||||
"[480,752][561,827]",
|
||||
"[567,752][645,827]",
|
||||
"[126,1000][438,1024]",
|
||||
)
|
||||
_TARGET = _PanelProfile(
|
||||
2,
|
||||
"[0,474][1080,861]",
|
||||
"[396,498][740,570]",
|
||||
"[396,503][722,570]",
|
||||
"快卖完 ¥32.76",
|
||||
"[396,582][1053,644]",
|
||||
"[396,750][645,825]",
|
||||
"[396,750][474,825]",
|
||||
"[480,750][561,825]",
|
||||
"[567,750][645,825]",
|
||||
"[126,998][438,1024]",
|
||||
)
|
||||
_PROFILES = {1: _INITIAL, 2: _TARGET}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Node:
|
||||
element: ElementTree.Element
|
||||
parent: "_Node | None"
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return self.element.get("text", "")
|
||||
|
||||
@property
|
||||
def desc(self) -> str:
|
||||
return self.element.get("content-desc", "")
|
||||
|
||||
@property
|
||||
def bounds(self) -> str:
|
||||
return self.element.get("bounds", "")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _VerifiedPanel:
|
||||
quantity: int
|
||||
panel_total_price: str
|
||||
plus_bounds: str
|
||||
projection: tuple[object, ...]
|
||||
|
||||
|
||||
class QuantityGate2Flow:
|
||||
"""从已确认数量 1 面板推进至获准数量,并安全退出同一商品。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: QuantityGate2Device,
|
||||
wait_timeout_seconds: float = 1.0,
|
||||
poll_interval_seconds: float = 0.2,
|
||||
monotonic_clock: Callable[[], float] = monotonic,
|
||||
sleep_function: Callable[[float], None] = sleep,
|
||||
) -> None:
|
||||
if not _positive_finite(wait_timeout_seconds) or not _positive_finite(poll_interval_seconds):
|
||||
raise ValueError("等待参数必须是大于 0 的有限数值。")
|
||||
self._device = device
|
||||
self._timeout = float(wait_timeout_seconds)
|
||||
self._poll = float(poll_interval_seconds)
|
||||
self._clock = monotonic_clock
|
||||
self._sleep = sleep_function
|
||||
self._increment_attempted = False
|
||||
self._pending: tuple[str, Callable[[list[_Node]], _VerifiedPanel]] | None = None
|
||||
self._terminal = False
|
||||
self._verified_quantity: int | None = None
|
||||
self._verified_total: str | None = None
|
||||
self._exited = False
|
||||
|
||||
@property
|
||||
def increment_attempts(self) -> int:
|
||||
return int(self._increment_attempted)
|
||||
|
||||
@property
|
||||
def exited(self) -> bool:
|
||||
return self._exited
|
||||
|
||||
@property
|
||||
def can_exit_safely(self) -> bool:
|
||||
return self._pending is None and self._verified_quantity in _PROFILES and not self._exited
|
||||
|
||||
def set_quantity_and_verify(
|
||||
self,
|
||||
gate1: Gate1Observation,
|
||||
target_quantity: int,
|
||||
max_total_price: str,
|
||||
) -> _VerifiedPanel:
|
||||
self._require_active()
|
||||
_validate_request(gate1, target_quantity, max_total_price)
|
||||
initial = self._read_verified(_INITIAL)
|
||||
if initial.panel_total_price != gate1.gate1_unit_price:
|
||||
raise QuantityGate2Error("Gate1 当前面板事实已漂移,已停止操作。")
|
||||
|
||||
if target_quantity == 1:
|
||||
verified = initial
|
||||
else:
|
||||
# 动作前 fresh 读取,不能使用上一次节点或缓存 bounds。
|
||||
before = self._read_hierarchy()
|
||||
precondition = _verified_panel(_parse_nodes(before), _INITIAL)
|
||||
_require_unique_action_occupants(_parse_nodes(before), precondition.plus_bounds)
|
||||
self._pending = (before, partial(_verified_panel, profile=_TARGET))
|
||||
self._increment_attempted = True
|
||||
try:
|
||||
self._device.increment_quantity_once(precondition.plus_bounds)
|
||||
verified = self._wait_for_pending()
|
||||
except BaseException:
|
||||
# 点击超时可能已送达;封存后不允许本 Flow 重试或继续。
|
||||
self._terminal = True
|
||||
raise
|
||||
|
||||
self._verified_quantity = verified.quantity
|
||||
self._verified_total = verified.panel_total_price
|
||||
if Decimal(verified.panel_total_price) > Decimal(max_total_price):
|
||||
raise QuantityGate2OverCapError("Gate2 面板总额超过授权最高总价,已停止操作。")
|
||||
return verified
|
||||
|
||||
def build_observation(
|
||||
self,
|
||||
gate1: Gate1Observation,
|
||||
target_quantity: int,
|
||||
max_total_price: str,
|
||||
screenshot_path: Path,
|
||||
captured_at: datetime,
|
||||
) -> Gate2Observation:
|
||||
self._require_active()
|
||||
_validate_request(gate1, target_quantity, max_total_price)
|
||||
if not isinstance(screenshot_path, Path) or not screenshot_path.name:
|
||||
raise QuantityGate2Error("Gate2 截图路径无效,已停止操作。")
|
||||
if not isinstance(captured_at, datetime) or captured_at.utcoffset() is None:
|
||||
raise QuantityGate2Error("Gate2 采集时间必须带时区,已停止操作。")
|
||||
verified = self._read_verified(_PROFILES[target_quantity])
|
||||
if (
|
||||
self._verified_quantity != verified.quantity
|
||||
or self._verified_total != verified.panel_total_price
|
||||
or Decimal(verified.panel_total_price) > Decimal(max_total_price)
|
||||
):
|
||||
raise QuantityGate2Error("截图后 Gate2 事实漂移,已停止操作。")
|
||||
return Gate2Observation(
|
||||
requested_color=gate1.color,
|
||||
requested_size=gate1.size,
|
||||
actual_color=TASK_COLOR,
|
||||
actual_size=TASK_SIZE,
|
||||
requested_quantity=target_quantity,
|
||||
quantity_read=verified.quantity,
|
||||
gate1_unit_price=gate1.gate1_unit_price,
|
||||
gate2_panel_total_price=verified.panel_total_price,
|
||||
max_total_price=max_total_price,
|
||||
screenshot_path=screenshot_path,
|
||||
captured_at=captured_at,
|
||||
)
|
||||
|
||||
def exit_sku_panel_safely(self) -> None:
|
||||
self._require_active()
|
||||
if self._verified_quantity not in _PROFILES:
|
||||
raise QuantityGate2Error("没有可用于安全退出的数量事实,已停止操作。")
|
||||
current = self._read_verified(_PROFILES[self._verified_quantity])
|
||||
if current.panel_total_price != self._verified_total:
|
||||
raise QuantityGate2Error("安全退出前 Gate2 事实漂移,已停止操作。")
|
||||
try:
|
||||
self._device.leave_sku_panel_once()
|
||||
except BaseException:
|
||||
self._terminal = True
|
||||
raise
|
||||
|
||||
deadline = self._clock() + self._timeout
|
||||
stable: tuple[object, ...] | None = None
|
||||
try:
|
||||
while True:
|
||||
self._require_environment()
|
||||
raw = self._read_hierarchy()
|
||||
try:
|
||||
projection = _product_exit_projection(_parse_sku_nodes(raw))
|
||||
except SkuSelectionError:
|
||||
stable = None
|
||||
else:
|
||||
self._require_environment()
|
||||
if stable == projection:
|
||||
self._exited = True
|
||||
self._terminal = True
|
||||
return
|
||||
stable = projection
|
||||
remaining = deadline - self._clock()
|
||||
if remaining <= 0:
|
||||
raise QuantityGate2TimeoutError("安全退出未达到连续稳定同商品判据,未重试返回。")
|
||||
self._sleep(min(self._poll, remaining))
|
||||
except BaseException:
|
||||
self._terminal = True
|
||||
raise
|
||||
|
||||
def reconcile_pending_action(self) -> _VerifiedPanel | None:
|
||||
"""结果不明时只读一次待定后置条件;绝不重发加号。"""
|
||||
|
||||
if self._pending is None:
|
||||
return None
|
||||
return self._wait_for_pending()
|
||||
|
||||
def _wait_for_pending(self) -> _VerifiedPanel:
|
||||
if self._pending is None:
|
||||
raise QuantityGate2Error("没有可调和的数量动作。")
|
||||
previous, condition = self._pending
|
||||
deadline = self._clock() + self._timeout
|
||||
while True:
|
||||
self._require_environment()
|
||||
raw = self._read_hierarchy()
|
||||
if raw != previous:
|
||||
try:
|
||||
verified = condition(_parse_nodes(raw))
|
||||
except QuantityGate2Error:
|
||||
pass
|
||||
else:
|
||||
self._pending = None
|
||||
return verified
|
||||
remaining = deadline - self._clock()
|
||||
if remaining <= 0:
|
||||
raise QuantityGate2TimeoutError("数量动作后置条件未确认,未重试加号。")
|
||||
self._sleep(min(self._poll, remaining))
|
||||
|
||||
def _read_verified(self, profile: _PanelProfile) -> _VerifiedPanel:
|
||||
self._require_environment()
|
||||
return _verified_panel(_parse_nodes(self._read_hierarchy()), profile)
|
||||
|
||||
def _require_environment(self) -> None:
|
||||
info = self._device.app_info(PDD_PACKAGE)
|
||||
version = (info.get("versionName") or info.get("version_name")) if isinstance(info, dict) else None
|
||||
if version != EXPECTED_PDD_VERSION:
|
||||
raise QuantityGate2Error("拼多多版本与 T-105 证据不一致,已停止操作。")
|
||||
foreground = self._device.current_foreground()
|
||||
if (
|
||||
not isinstance(foreground, dict)
|
||||
or foreground.get("package") != PDD_PACKAGE
|
||||
or not isinstance(foreground.get("activity"), str)
|
||||
or not foreground["activity"].strip()
|
||||
):
|
||||
raise QuantityGate2Error("拼多多不是唯一前台应用,已停止操作。")
|
||||
if self._device.display_size() != EXPECTED_SCREEN_SIZE:
|
||||
raise QuantityGate2Error("屏幕坐标空间与 T-105 证据不一致,已停止操作。")
|
||||
|
||||
def _read_hierarchy(self) -> str:
|
||||
value = self._device.dump_window_hierarchy()
|
||||
if not isinstance(value, str) or not value:
|
||||
raise QuantityGate2Error("节点树读取失败,已停止操作。")
|
||||
return value
|
||||
|
||||
def _require_active(self) -> None:
|
||||
if self._terminal:
|
||||
raise QuantityGate2Error("数量流程已进入不可重入终止态。")
|
||||
|
||||
|
||||
def _positive_finite(value: object) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
|
||||
|
||||
|
||||
def _validate_request(gate1: object, target_quantity: object, max_total_price: object) -> None:
|
||||
if not isinstance(gate1, Gate1Observation):
|
||||
raise QuantityGate2Error("缺少可信 Gate1Observation,已停止操作。")
|
||||
if type(target_quantity) is not int or target_quantity not in _PROFILES:
|
||||
raise QuantityGate2Error("目标数量没有本项目真机证据,已停止操作。")
|
||||
_money(max_total_price)
|
||||
|
||||
|
||||
def _money(value: object) -> str:
|
||||
try:
|
||||
return require_money(value, "invalid_money")
|
||||
except ValidationError as error:
|
||||
raise QuantityGate2Error("金额不是规范十进制字符串,已停止操作。") from error
|
||||
|
||||
|
||||
def _parse_nodes(raw: str) -> list[_Node]:
|
||||
try:
|
||||
root = ElementTree.fromstring(raw)
|
||||
except ElementTree.ParseError as error:
|
||||
raise QuantityGate2Error("节点树格式无效,已停止操作。") from error
|
||||
if root.tag != "hierarchy":
|
||||
raise QuantityGate2Error("节点树根节点无效,已停止操作。")
|
||||
result: list[_Node] = []
|
||||
|
||||
def visit(element: ElementTree.Element, parent: _Node | None) -> None:
|
||||
node = _Node(element, parent)
|
||||
result.append(node)
|
||||
for child in element:
|
||||
visit(child, node)
|
||||
|
||||
visit(root, None)
|
||||
return result
|
||||
|
||||
|
||||
def _verified_panel(nodes: list[_Node], profile: _PanelProfile) -> _VerifiedPanel:
|
||||
panel = _one(
|
||||
node for node in nodes
|
||||
if _exact(node, "android.view.ViewGroup", profile.panel_bounds, resource_id=_PDD_ID, clickable="false")
|
||||
)
|
||||
price_frame = _one(
|
||||
node for node in nodes
|
||||
if node.parent is panel and _exact(node, "android.widget.FrameLayout", "[396,498][1053,570]", resource_id=_PDD_ID, clickable="false")
|
||||
)
|
||||
price_row = _one(
|
||||
node for node in nodes
|
||||
if node.parent is price_frame and _exact(node, "android.widget.LinearLayout", profile.price_row_bounds, resource_id=_PDD_ID, clickable="false")
|
||||
)
|
||||
price = _one(
|
||||
node for node in nodes
|
||||
if node.parent is price_row
|
||||
and _exact(node, "android.widget.TextView", profile.price_bounds, resource_id=_PDD_ID, clickable="false")
|
||||
and node.text == profile.price_text
|
||||
and not node.desc
|
||||
)
|
||||
match = _AMOUNT_TEXT.fullmatch(price.text)
|
||||
if match is None:
|
||||
raise QuantityGate2Error("Gate2 面板总额角色不可读。")
|
||||
amount = _money(match.group("amount"))
|
||||
|
||||
_one(
|
||||
node for node in nodes
|
||||
if node.parent is panel
|
||||
and _exact(node, "android.widget.TextView", profile.summary_bounds, resource_id=_PDD_ID, clickable="false")
|
||||
and node.text == _TARGET_SUMMARY
|
||||
and not node.desc
|
||||
)
|
||||
quantity_outer = _one(
|
||||
node for node in nodes
|
||||
if node.parent is panel
|
||||
and _exact(node, "android.widget.LinearLayout", profile.quantity_bounds, resource_id=_QUANTITY_CONTAINER_ID, clickable="false")
|
||||
)
|
||||
quantity_inner = _one(
|
||||
node for node in nodes
|
||||
if node.parent is quantity_outer
|
||||
and _exact(node, "android.widget.LinearLayout", profile.quantity_bounds, resource_id="", clickable="false")
|
||||
)
|
||||
_one(
|
||||
node for node in nodes
|
||||
if node.parent is quantity_inner
|
||||
and _exact(node, "android.widget.ImageView", profile.minus_bounds, resource_id=_PDD_ID, clickable="true")
|
||||
and node.desc == "减少数量"
|
||||
and not node.text
|
||||
)
|
||||
quantity = _one(
|
||||
node for node in nodes
|
||||
if node.parent is quantity_inner
|
||||
and _exact(node, "android.widget.EditText", profile.value_bounds, resource_id=_PDD_ID, clickable="true")
|
||||
and node.text == str(profile.quantity)
|
||||
and not node.desc
|
||||
)
|
||||
plus = _one(
|
||||
node for node in nodes
|
||||
if node.parent is quantity_inner
|
||||
and _exact(node, "android.widget.ImageView", profile.plus_bounds, resource_id=_PDD_ID, clickable="true")
|
||||
and node.desc == "增加数量"
|
||||
and not node.text
|
||||
)
|
||||
if quantity.element.get("selected") != "false" or plus.element.get("selected") != "false":
|
||||
raise QuantityGate2Error("数量控件选中属性漂移。")
|
||||
|
||||
_require_selected_target(nodes, profile)
|
||||
projection = (
|
||||
"quantity_gate2_8_17_0",
|
||||
profile.quantity,
|
||||
amount,
|
||||
tuple(_projection(node) for node in (panel, price_frame, price_row, price, quantity_outer, quantity_inner, quantity, plus)),
|
||||
)
|
||||
return _VerifiedPanel(profile.quantity, amount, plus.bounds, projection)
|
||||
|
||||
|
||||
def _require_selected_target(nodes: list[_Node], profile: _PanelProfile) -> None:
|
||||
color = _one(
|
||||
node for node in nodes
|
||||
if _exact(node, "android.widget.TextView", profile.color_bounds, resource_id=_COLOR_ID, clickable="true", selected="true")
|
||||
and node.text == UI_COLOR
|
||||
and not node.desc
|
||||
)
|
||||
if color.parent is None or color.parent.element.get("selected") != "true":
|
||||
raise QuantityGate2Error("目标颜色没有精确选中。")
|
||||
size = _one(
|
||||
node for node in nodes
|
||||
if _exact(node, "android.widget.TextView", "[439,1582][831,1667]", resource_id=_PDD_ID, clickable="true", selected="true")
|
||||
and node.text == UI_SIZE
|
||||
and not node.desc
|
||||
)
|
||||
if size.parent is None or size.parent.element.get("clickable") != "true":
|
||||
raise QuantityGate2Error("目标尺码结构漂移。")
|
||||
|
||||
|
||||
def _exact(
|
||||
node: _Node,
|
||||
class_name: str,
|
||||
bounds: str,
|
||||
*,
|
||||
resource_id: str,
|
||||
clickable: str,
|
||||
selected: str = "false",
|
||||
) -> bool:
|
||||
element = node.element
|
||||
return (
|
||||
element.get("package") == PDD_PACKAGE
|
||||
and element.get("class") == class_name
|
||||
and node.bounds == bounds
|
||||
and element.get("resource-id", "") == resource_id
|
||||
and element.get("clickable") == clickable
|
||||
and element.get("selected") == selected
|
||||
and element.get("enabled") == "true"
|
||||
and element.get("visible-to-user") == "true"
|
||||
and element.get("scrollable") == "false"
|
||||
)
|
||||
|
||||
|
||||
def _one(values: Any) -> _Node:
|
||||
matches = list(values)
|
||||
if len(matches) != 1:
|
||||
raise QuantityGate2Error("T-105 页面证据角色缺失或不唯一。")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _projection(node: _Node) -> tuple[str, ...]:
|
||||
return (
|
||||
node.element.tag,
|
||||
node.element.get("package", ""),
|
||||
node.element.get("class", ""),
|
||||
node.bounds,
|
||||
node.element.get("resource-id", ""),
|
||||
node.element.get("clickable", ""),
|
||||
node.element.get("selected", ""),
|
||||
node.text,
|
||||
node.desc,
|
||||
)
|
||||
|
||||
|
||||
def _bounds_center(bounds: str) -> tuple[int, int]:
|
||||
match = _BOUNDS.fullmatch(bounds)
|
||||
if match is None:
|
||||
raise QuantityGate2Error("数量控件坐标无效。")
|
||||
left, top, right, bottom = (int(value) for value in match.groups())
|
||||
if not (0 <= left < right <= EXPECTED_SCREEN_SIZE[0] and 0 <= top < bottom <= EXPECTED_SCREEN_SIZE[1]):
|
||||
raise QuantityGate2Error("数量控件坐标超出已取证屏幕。")
|
||||
return left + (right - left) // 2, top + (bottom - top) // 2
|
||||
|
||||
|
||||
def _require_unique_action_occupants(nodes: list[_Node], bounds: str) -> None:
|
||||
target = _one(node for node in nodes if node.bounds == bounds and node.desc == "增加数量")
|
||||
x, y = _bounds_center(bounds)
|
||||
occupants = [
|
||||
node for node in nodes
|
||||
if node.element.get("clickable") == "true"
|
||||
and node.element.get("enabled") == "true"
|
||||
and node.element.get("visible-to-user") == "true"
|
||||
and _contains(node.bounds, x, y)
|
||||
]
|
||||
if not occupants or any(not _same_branch(node, target) for node in occupants):
|
||||
raise QuantityGate2Error("数量加号中心存在未知可点击覆盖层,已停止操作。")
|
||||
|
||||
|
||||
def _contains(bounds: str, x: int, y: int) -> bool:
|
||||
match = _BOUNDS.fullmatch(bounds)
|
||||
if match is None:
|
||||
return False
|
||||
left, top, right, bottom = (int(value) for value in match.groups())
|
||||
return left <= x < right and top <= y < bottom
|
||||
|
||||
|
||||
def _same_branch(candidate: _Node, target: _Node) -> bool:
|
||||
current: _Node | None = target
|
||||
while current is not None:
|
||||
if current.element is candidate.element:
|
||||
return True
|
||||
current = current.parent
|
||||
current = candidate
|
||||
while current is not None:
|
||||
if current.element is target.element:
|
||||
return True
|
||||
current = current.parent
|
||||
return False
|
||||
@@ -0,0 +1,397 @@
|
||||
"""T-105 真机运行边界:一次数量加号、Gate2 原图与一次安全退出。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from math import isfinite
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from time import monotonic
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from adbutils.errors import AdbTimeout
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from uiautomator2.exceptions import HTTPTimeoutError
|
||||
|
||||
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
|
||||
from ..device.baseline import PDD_PACKAGE, SCREENSHOT_PARAMS, _save_base64_screenshot, _sha256_file
|
||||
from .product_open import EXPECTED_PDD_VERSION
|
||||
from .quantity_gate2 import (
|
||||
EXPECTED_ANDROID_VERSION,
|
||||
EXPECTED_DEVICE_MODEL,
|
||||
EXPECTED_GOODS_ID,
|
||||
EXPECTED_SCREEN_SIZE,
|
||||
Gate1Observation,
|
||||
Gate2Observation,
|
||||
QuantityGate2Device,
|
||||
QuantityGate2Error,
|
||||
QuantityGate2Flow,
|
||||
QuantityGate2TimeoutError,
|
||||
_bounds_center,
|
||||
_money,
|
||||
)
|
||||
|
||||
|
||||
class ForegroundReader(Protocol):
|
||||
def read(self, serial: str) -> dict[str, str]: ...
|
||||
|
||||
|
||||
class QuantityGate2AdapterError(QuantityGate2Error):
|
||||
"""第三方设备接口失败后的脱敏映射。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuantityGate2RunResult:
|
||||
output_directory: Path
|
||||
screenshot_path: Path
|
||||
manifest_path: Path
|
||||
observation: Gate2Observation
|
||||
|
||||
|
||||
class UiautomatorQuantityGate2Adapter(QuantityGate2Device):
|
||||
"""只暴露 T-105 已批准的一个加号和一个 Back。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: Any,
|
||||
foreground_reader: ForegroundReader,
|
||||
serial: str,
|
||||
timeout_seconds: float,
|
||||
) -> None:
|
||||
if type(serial) is not str or not serial.strip() or serial != serial.strip():
|
||||
raise ValueError("serial 必须显式且非空。")
|
||||
if not _positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值。")
|
||||
self._device = device
|
||||
self._foreground_reader = foreground_reader
|
||||
self._serial = serial
|
||||
self._timeout = float(timeout_seconds)
|
||||
self._increment_attempted = False
|
||||
self._increment_bounds: str | None = None
|
||||
self._increment_outcome = "not_attempted"
|
||||
self._back_attempted = False
|
||||
self._back_outcome = "not_attempted"
|
||||
|
||||
@property
|
||||
def increment_attempts(self) -> int:
|
||||
return int(self._increment_attempted)
|
||||
|
||||
@property
|
||||
def increment_bounds(self) -> str | None:
|
||||
return self._increment_bounds
|
||||
|
||||
@property
|
||||
def increment_outcome(self) -> str:
|
||||
return self._increment_outcome
|
||||
|
||||
@property
|
||||
def back_attempts(self) -> int:
|
||||
return int(self._back_attempted)
|
||||
|
||||
@property
|
||||
def back_outcome(self) -> str:
|
||||
return self._back_outcome
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]:
|
||||
value = self._call("app_info", package_name)
|
||||
if not isinstance(value, dict):
|
||||
raise QuantityGate2AdapterError("无法读取应用版本,已停止操作。")
|
||||
return value
|
||||
|
||||
def current_foreground(self) -> dict[str, str]:
|
||||
try:
|
||||
value = self._foreground_reader.read(self._serial)
|
||||
except QuantityGate2Error:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise QuantityGate2AdapterError("无法读取 Android 前台摘要,已停止操作。") from error
|
||||
if not isinstance(value, dict):
|
||||
raise QuantityGate2AdapterError("Android 前台摘要无效,已停止操作。")
|
||||
return value
|
||||
|
||||
def display_size(self) -> tuple[int, int]:
|
||||
value = self._call("window_size")
|
||||
if not isinstance(value, tuple) or len(value) != 2 or any(type(item) is not int for item in value):
|
||||
raise QuantityGate2AdapterError("无法读取屏幕坐标空间,已停止操作。")
|
||||
return value
|
||||
|
||||
def dump_window_hierarchy(self) -> str:
|
||||
value = self._call("jsonrpc_call", "dumpWindowHierarchy", [False, 50], timeout=self._timeout)
|
||||
if not isinstance(value, str):
|
||||
raise QuantityGate2AdapterError("节点树读取失败,已停止操作。")
|
||||
return value
|
||||
|
||||
def increment_quantity_once(self, bounds: str) -> None:
|
||||
if self._increment_attempted:
|
||||
raise QuantityGate2AdapterError("数量加号已尝试过,拒绝重试。")
|
||||
center_x, center_y = _bounds_center(bounds)
|
||||
# RPC 超时无法证明事件未送达,动作机会必须先持久在内存审计状态中。
|
||||
self._increment_attempted = True
|
||||
self._increment_bounds = bounds
|
||||
self._increment_outcome = "ambiguous"
|
||||
self._call("jsonrpc_call", "click", [center_x, center_y], timeout=self._timeout)
|
||||
self._increment_outcome = "completed"
|
||||
|
||||
def capture_screenshot(self) -> str:
|
||||
value = self._call("jsonrpc_call", "takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout)
|
||||
if not isinstance(value, str):
|
||||
raise QuantityGate2AdapterError("Gate2 原始截图读取失败,已停止操作。")
|
||||
return value
|
||||
|
||||
def leave_sku_panel_once(self) -> None:
|
||||
if self._back_attempted:
|
||||
raise QuantityGate2AdapterError("安全返回已尝试过,拒绝重试。")
|
||||
self._back_attempted = True
|
||||
self._back_outcome = "ambiguous"
|
||||
self._call("jsonrpc_call", "pressKey", ["back"], timeout=self._timeout)
|
||||
self._back_outcome = "completed"
|
||||
|
||||
def _call(self, method: str, *args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
return getattr(self._device, method)(*args, **kwargs)
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
raise QuantityGate2TimeoutError("T-105 设备调用超时,已停止操作。") from error
|
||||
except QuantityGate2Error:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise QuantityGate2AdapterError("T-105 设备调用失败,已停止操作。") from error
|
||||
|
||||
|
||||
class QuantityGate2Runner:
|
||||
"""从人工停驻的数量 1 目标面板执行 T-105 已取证闭环。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adb_client: AdbClient,
|
||||
connector: Callable[[str], Any],
|
||||
foreground_reader: ForegroundReader,
|
||||
timeout_seconds: float,
|
||||
monotonic_clock: Callable[[], float] = monotonic,
|
||||
) -> None:
|
||||
if not _positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值。")
|
||||
self._adb_client = adb_client
|
||||
self._connector = connector
|
||||
self._foreground_reader = foreground_reader
|
||||
self._timeout = float(timeout_seconds)
|
||||
self._clock = monotonic_clock
|
||||
|
||||
def run(
|
||||
self,
|
||||
serial: str,
|
||||
goods_id: str,
|
||||
gate1: Gate1Observation,
|
||||
target_quantity: int,
|
||||
max_total_price: str,
|
||||
output_directory: Path,
|
||||
) -> QuantityGate2RunResult:
|
||||
target = Path(output_directory)
|
||||
staging: Path | None = None
|
||||
adapter: UiautomatorQuantityGate2Adapter | None = None
|
||||
flow: QuantityGate2Flow | None = None
|
||||
try:
|
||||
_validate_preflight(serial, goods_id, gate1, target_quantity, max_total_price, target)
|
||||
staging = _prepare_staging(target)
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
_require_expected_device(inspection)
|
||||
adapter = UiautomatorQuantityGate2Adapter(
|
||||
self._connector(serial),
|
||||
self._foreground_reader,
|
||||
serial,
|
||||
self._timeout,
|
||||
)
|
||||
flow = QuantityGate2Flow(
|
||||
adapter,
|
||||
wait_timeout_seconds=self._timeout,
|
||||
monotonic_clock=self._clock,
|
||||
)
|
||||
flow.set_quantity_and_verify(gate1, target_quantity, max_total_price)
|
||||
|
||||
screenshot_path = staging / "gate2_screenshot.png"
|
||||
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
|
||||
captured_at = datetime.now(UTC)
|
||||
_require_screenshot_size(screenshot_path)
|
||||
observation = flow.build_observation(
|
||||
gate1,
|
||||
target_quantity,
|
||||
max_total_price,
|
||||
screenshot_path,
|
||||
captured_at,
|
||||
)
|
||||
flow.exit_sku_panel_safely()
|
||||
_require_action_audit(adapter, target_quantity)
|
||||
|
||||
manifest_path = staging / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
_manifest(inspection, serial, gate1, observation, screenshot_path, adapter),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.rename(staging, target)
|
||||
staging = None
|
||||
except (DeviceConnectionError, QuantityGate2Error):
|
||||
_attempt_known_safe_exit(flow)
|
||||
_clean_staging(staging)
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
_attempt_known_safe_exit(flow)
|
||||
_clean_staging(staging)
|
||||
raise QuantityGate2TimeoutError("T-105 真机运行超时,未发布证据。") from error
|
||||
except OSError as error:
|
||||
_attempt_known_safe_exit(flow)
|
||||
_clean_staging(staging)
|
||||
raise QuantityGate2Error("T-105 证据无法原子发布。") from error
|
||||
except Exception as error:
|
||||
_attempt_known_safe_exit(flow)
|
||||
_clean_staging(staging)
|
||||
raise QuantityGate2Error("T-105 真机运行未完成。") from error
|
||||
|
||||
published_observation = replace(
|
||||
observation,
|
||||
screenshot_path=target / "gate2_screenshot.png",
|
||||
)
|
||||
return QuantityGate2RunResult(
|
||||
output_directory=target,
|
||||
screenshot_path=target / "gate2_screenshot.png",
|
||||
manifest_path=target / "manifest.json",
|
||||
observation=published_observation,
|
||||
)
|
||||
|
||||
|
||||
def _positive_finite(value: object) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
|
||||
|
||||
|
||||
def _validate_preflight(
|
||||
serial: object,
|
||||
goods_id: object,
|
||||
gate1: object,
|
||||
target_quantity: object,
|
||||
max_total_price: object,
|
||||
target: Path,
|
||||
) -> None:
|
||||
if type(serial) is not str or not serial.strip() or serial != serial.strip():
|
||||
raise QuantityGate2Error("必须显式提供非空设备通道。")
|
||||
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
|
||||
raise QuantityGate2Error("商品不是 T-105 已批准目标。")
|
||||
if not isinstance(gate1, Gate1Observation) or not gate1.screenshot_path.is_file():
|
||||
raise QuantityGate2Error("Gate1 原始截图不存在,已停止操作。")
|
||||
if type(target_quantity) is not int or target_quantity not in {1, 2}:
|
||||
raise QuantityGate2Error("目标数量没有 T-105 真机证据。")
|
||||
_money(max_total_price)
|
||||
if target.exists() or not target.name:
|
||||
raise QuantityGate2Error("输出目录必须是不存在的明确新目录。")
|
||||
|
||||
|
||||
def _prepare_staging(target: Path) -> Path:
|
||||
staging: Path | None = None
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
||||
staging.mkdir()
|
||||
return staging
|
||||
except OSError as error:
|
||||
_clean_staging(staging)
|
||||
raise QuantityGate2Error("输出目录不可写,已停止操作。") from error
|
||||
|
||||
|
||||
def _clean_staging(staging: Path | None) -> None:
|
||||
if staging is not None and staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
|
||||
|
||||
def _require_expected_device(inspection: DeviceInspection) -> None:
|
||||
if inspection.model != EXPECTED_DEVICE_MODEL or inspection.android_version != EXPECTED_ANDROID_VERSION:
|
||||
raise QuantityGate2Error("设备型号或 Android 版本与 T-105 证据不一致。")
|
||||
|
||||
|
||||
def _require_screenshot_size(path: Path) -> None:
|
||||
try:
|
||||
with Image.open(path) as image:
|
||||
image.load()
|
||||
if image.size != EXPECTED_SCREEN_SIZE:
|
||||
raise QuantityGate2Error("Gate2 截图尺寸与 T-105 证据不一致。")
|
||||
except QuantityGate2Error:
|
||||
raise
|
||||
except (OSError, UnidentifiedImageError) as error:
|
||||
raise QuantityGate2Error("Gate2 截图不是有效图像。") from error
|
||||
|
||||
|
||||
def _require_action_audit(adapter: UiautomatorQuantityGate2Adapter, target_quantity: int) -> None:
|
||||
expected_increment = int(target_quantity == 2)
|
||||
if (
|
||||
adapter.increment_attempts != expected_increment
|
||||
or (expected_increment and adapter.increment_outcome != "completed")
|
||||
or (expected_increment and adapter.increment_bounds != "[567,752][645,827]")
|
||||
or (not expected_increment and adapter.increment_outcome != "not_attempted")
|
||||
or adapter.back_attempts != 1
|
||||
or adapter.back_outcome != "completed"
|
||||
):
|
||||
raise QuantityGate2Error("T-105 动作审计链不完整,拒绝发布。")
|
||||
|
||||
|
||||
def _attempt_known_safe_exit(flow: QuantityGate2Flow | None) -> None:
|
||||
if flow is None or not flow.can_exit_safely:
|
||||
return
|
||||
try:
|
||||
flow.exit_sku_panel_safely()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
gate1: Gate1Observation,
|
||||
observation: Gate2Observation,
|
||||
screenshot_path: Path,
|
||||
adapter: UiautomatorQuantityGate2Adapter,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"operation": "t105-quantity-gate2",
|
||||
"captured_at": observation.captured_at.isoformat(),
|
||||
"product": {"goods_id": EXPECTED_GOODS_ID},
|
||||
"channel": "wifi" if ":" in serial else "usb",
|
||||
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||
"device": {
|
||||
"model": inspection.model,
|
||||
"android_version": inspection.android_version,
|
||||
"pdd_package": PDD_PACKAGE,
|
||||
"pdd_version": EXPECTED_PDD_VERSION,
|
||||
},
|
||||
"selection": {"color": observation.actual_color, "size": observation.actual_size},
|
||||
"quantity": {"requested": observation.requested_quantity, "read": observation.quantity_read},
|
||||
"prices": {
|
||||
"gate1_unit_price": observation.gate1_unit_price,
|
||||
"gate2_panel_total_price": observation.gate2_panel_total_price,
|
||||
"max_total_price": observation.max_total_price,
|
||||
},
|
||||
"gate1_evidence": {
|
||||
"captured_at": gate1.captured_at.isoformat(),
|
||||
"screenshot_sha256": _sha256_file(gate1.screenshot_path),
|
||||
},
|
||||
"gate2_evidence": {
|
||||
"path": screenshot_path.name,
|
||||
"sha256": _sha256_file(screenshot_path),
|
||||
},
|
||||
"action_audit": {
|
||||
"increment_attempts": adapter.increment_attempts,
|
||||
"increment_rpc_outcome": adapter.increment_outcome,
|
||||
"back_attempts": adapter.back_attempts,
|
||||
"back_rpc_outcome": adapter.back_outcome,
|
||||
},
|
||||
"safe_exit": "completed",
|
||||
"review_status": "human_review_required",
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
"""T-105 数量两态的纯只读取证;人工确认前不识别或操作数量控件。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from math import isfinite
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from adbutils.errors import AdbTimeout
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from uiautomator2.exceptions import HTTPTimeoutError
|
||||
|
||||
from ..device.adb import AdbClient, CommandRunner, DeviceConnectionError, DeviceInspection
|
||||
from ..device.baseline import (
|
||||
HIERARCHY_PARAMS,
|
||||
PDD_PACKAGE,
|
||||
SCREENSHOT_PARAMS,
|
||||
_save_base64_screenshot,
|
||||
_sha256_file,
|
||||
_validate_hierarchy,
|
||||
)
|
||||
from .product_open import EXPECTED_PDD_VERSION
|
||||
from .product_url import parse_product_url
|
||||
|
||||
|
||||
EXPECTED_GOODS_ID = "937122477375"
|
||||
EXPECTED_DEVICE_MODEL = "PKG110"
|
||||
EXPECTED_ANDROID_VERSION = "16"
|
||||
EXPECTED_SCREEN_SIZE = (1080, 2376)
|
||||
TARGET_SELECTION = {"color": "黑色CHA(纯棉)", "size": "M(建议100-115)"}
|
||||
DECLARED_QUANTITIES = {"initial": 1, "target": 2}
|
||||
|
||||
|
||||
class QuantityGate2EvidenceError(RuntimeError):
|
||||
"""T-105 两态证据未形成完整原子产物。"""
|
||||
|
||||
|
||||
class QuantityGate2EvidenceTimeoutError(QuantityGate2EvidenceError):
|
||||
"""只读取证设备调用超时。"""
|
||||
|
||||
|
||||
class QuantityGate2ReadDevice(Protocol):
|
||||
"""阶段一唯一设备边界;故意不暴露任何页面动作。"""
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]: ...
|
||||
|
||||
def window_size(self) -> tuple[int, int]: ...
|
||||
|
||||
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any: ...
|
||||
|
||||
|
||||
class QuantityGate2ForegroundReader(Protocol):
|
||||
"""只读取 Android 16 的唯一 resumed activity,不暴露通用 shell。"""
|
||||
|
||||
def read(self, serial: str) -> dict[str, str]: ...
|
||||
|
||||
|
||||
class Android16TopResumedForegroundReader:
|
||||
"""绕开 adbutils 2.12.0 对 Android 16 ``topResumedActivity`` 的误解析。"""
|
||||
|
||||
_TOP_RESUMED_PATTERN = re.compile(
|
||||
r"(?m)^\s*topResumedActivity=ActivityRecord\{[^\r\n}]*?\s+u\d+\s+"
|
||||
r"(?P<package>[^/\s]+)/(?P<activity>[^\s}]+)\s+t\d+\}\s*$"
|
||||
)
|
||||
|
||||
def __init__(self, runner: CommandRunner, timeout_seconds: float) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._runner = runner
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
def read(self, serial: str) -> dict[str, str]:
|
||||
if type(serial) is not str or not serial.strip() or serial != serial.strip():
|
||||
raise QuantityGate2EvidenceError("必须显式提供非空设备通道。")
|
||||
result = self._runner.run(
|
||||
("-s", serial, "shell", "dumpsys", "activity", "activities"),
|
||||
self._timeout_seconds,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise QuantityGate2EvidenceError("Android 前台摘要读取失败,未发布证据。")
|
||||
matches = list(self._TOP_RESUMED_PATTERN.finditer(result.stdout))
|
||||
if len(matches) != 1:
|
||||
raise QuantityGate2EvidenceError("Android 前台摘要不唯一,未发布证据。")
|
||||
match = matches[0]
|
||||
return {
|
||||
"package": match.group("package"),
|
||||
"activity": match.group("activity"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuantityGate2EvidenceResult:
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
screenshot_path: Path
|
||||
hierarchy_path: Path
|
||||
app_path: Path
|
||||
|
||||
|
||||
class QuantityGate2EvidenceCapturer:
|
||||
"""记录人工准备的数量状态,不从页面推断声明是否正确。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adb_client: AdbClient,
|
||||
connector: Callable[[str], QuantityGate2ReadDevice],
|
||||
foreground_reader: QuantityGate2ForegroundReader,
|
||||
timeout_seconds: float,
|
||||
) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._adb_client = adb_client
|
||||
self._connector = connector
|
||||
self._foreground_reader = foreground_reader
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._started = False
|
||||
|
||||
def capture(
|
||||
self,
|
||||
serial: str,
|
||||
goods_id: str,
|
||||
human_declared_state: str,
|
||||
human_declared_quantity: int,
|
||||
output_directory: Path,
|
||||
) -> QuantityGate2EvidenceResult:
|
||||
if self._started:
|
||||
raise QuantityGate2EvidenceError("同一取证器不可重复调用。")
|
||||
self._started = True
|
||||
_validate_inputs(serial, goods_id, human_declared_state, human_declared_quantity)
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
|
||||
staging: Path | None = None
|
||||
try:
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
_require_expected_device(inspection)
|
||||
device = self._connector(serial)
|
||||
initial_app = _require_read_precondition(
|
||||
device,
|
||||
self._foreground_reader.read(serial),
|
||||
)
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
||||
staging.mkdir()
|
||||
|
||||
screenshot_path = staging / "screenshot.png"
|
||||
screenshot_payload = _read_rpc(
|
||||
device,
|
||||
"takeScreenshot",
|
||||
SCREENSHOT_PARAMS,
|
||||
self._timeout_seconds,
|
||||
)
|
||||
if not isinstance(screenshot_payload, str):
|
||||
raise QuantityGate2EvidenceError("数量状态截图无效,未发布证据。")
|
||||
_save_base64_screenshot(screenshot_payload, screenshot_path)
|
||||
_require_screenshot_size(screenshot_path)
|
||||
|
||||
hierarchy = _read_rpc(
|
||||
device,
|
||||
"dumpWindowHierarchy",
|
||||
HIERARCHY_PARAMS,
|
||||
self._timeout_seconds,
|
||||
)
|
||||
_validate_hierarchy(hierarchy)
|
||||
hierarchy_path = staging / "hierarchy.xml"
|
||||
hierarchy_path.write_text(hierarchy, encoding="utf-8")
|
||||
|
||||
final_app = _require_read_precondition(
|
||||
device,
|
||||
self._foreground_reader.read(serial),
|
||||
)
|
||||
if final_app != initial_app:
|
||||
raise QuantityGate2EvidenceError("数量状态取证期间前台页面漂移,未发布证据。")
|
||||
app_path = staging / "app.json"
|
||||
app_path.write_text(
|
||||
json.dumps(final_app, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
manifest_path = staging / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
_manifest(
|
||||
inspection,
|
||||
serial,
|
||||
goods_id,
|
||||
human_declared_state,
|
||||
human_declared_quantity,
|
||||
screenshot_path,
|
||||
hierarchy_path,
|
||||
app_path,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.rename(staging, target)
|
||||
staging = None
|
||||
except (DeviceConnectionError, QuantityGate2EvidenceError):
|
||||
_clean_staging(staging)
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
_clean_staging(staging)
|
||||
raise QuantityGate2EvidenceTimeoutError("数量两态只读取证超时,未发布证据。") from error
|
||||
except (OSError, UnidentifiedImageError, ValueError) as error:
|
||||
_clean_staging(staging)
|
||||
raise QuantityGate2EvidenceError("数量两态证据无法原子发布,未发布证据。") from error
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
raise QuantityGate2EvidenceError("数量两态只读取证未完成,未发布证据。") from error
|
||||
|
||||
return QuantityGate2EvidenceResult(
|
||||
output_directory=target,
|
||||
manifest_path=target / "manifest.json",
|
||||
screenshot_path=target / "screenshot.png",
|
||||
hierarchy_path=target / "hierarchy.xml",
|
||||
app_path=target / "app.json",
|
||||
)
|
||||
|
||||
|
||||
def _is_positive_finite(value: object) -> bool:
|
||||
return (
|
||||
isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and value > 0
|
||||
and isfinite(value)
|
||||
)
|
||||
|
||||
|
||||
def _validate_inputs(serial: object, goods_id: object, state: object, quantity: object) -> None:
|
||||
if type(serial) is not str or not serial.strip() or serial != serial.strip():
|
||||
raise QuantityGate2EvidenceError("必须显式提供非空设备通道。")
|
||||
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
|
||||
raise QuantityGate2EvidenceError("商品不是 T-105 已批准取证目标。")
|
||||
parse_product_url(f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}")
|
||||
if type(state) is not str or state not in DECLARED_QUANTITIES:
|
||||
raise QuantityGate2EvidenceError("人工声明状态无效。")
|
||||
if type(quantity) is not int or quantity != DECLARED_QUANTITIES[state]:
|
||||
raise QuantityGate2EvidenceError("人工声明数量与批准状态不一致。")
|
||||
|
||||
|
||||
def _validate_new_target(target: Path) -> None:
|
||||
if target.exists() or not target.name:
|
||||
raise QuantityGate2EvidenceError("输出目录必须是不存在的明确新目录。")
|
||||
|
||||
|
||||
def _require_expected_device(inspection: DeviceInspection) -> None:
|
||||
if inspection.model != EXPECTED_DEVICE_MODEL or inspection.android_version != EXPECTED_ANDROID_VERSION:
|
||||
raise QuantityGate2EvidenceError("设备不是已批准取证组合。")
|
||||
|
||||
|
||||
def _require_read_precondition(
|
||||
device: QuantityGate2ReadDevice,
|
||||
current: object,
|
||||
) -> dict[str, str]:
|
||||
info = device.app_info(PDD_PACKAGE)
|
||||
version = (info.get("versionName") or info.get("version_name")) if isinstance(info, dict) else None
|
||||
if version != EXPECTED_PDD_VERSION:
|
||||
raise QuantityGate2EvidenceError("拼多多版本不是已批准取证版本。")
|
||||
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
|
||||
raise QuantityGate2EvidenceError("拼多多不在前台。")
|
||||
activity = current.get("activity")
|
||||
if not isinstance(activity, str) or not activity.strip():
|
||||
raise QuantityGate2EvidenceError("前台应用摘要不完整。")
|
||||
if device.window_size() != EXPECTED_SCREEN_SIZE:
|
||||
raise QuantityGate2EvidenceError("屏幕坐标空间不是已批准尺寸。")
|
||||
return {"package": PDD_PACKAGE, "activity": activity, "pdd_version": EXPECTED_PDD_VERSION}
|
||||
|
||||
|
||||
def _read_rpc(
|
||||
device: QuantityGate2ReadDevice,
|
||||
method: str,
|
||||
params: object,
|
||||
timeout_seconds: float,
|
||||
) -> object:
|
||||
return device.jsonrpc_call(method, params, timeout=timeout_seconds)
|
||||
|
||||
|
||||
def _require_screenshot_size(path: Path) -> None:
|
||||
with Image.open(path) as image:
|
||||
image.load()
|
||||
if image.size != EXPECTED_SCREEN_SIZE or image.format != "PNG":
|
||||
raise QuantityGate2EvidenceError("数量状态截图格式或尺寸无效。")
|
||||
|
||||
|
||||
def _clean_staging(staging: Path | None) -> None:
|
||||
if staging is not None and staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
|
||||
|
||||
def _manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
goods_id: str,
|
||||
state: str,
|
||||
quantity: int,
|
||||
screenshot_path: Path,
|
||||
hierarchy_path: Path,
|
||||
app_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"operation": "t105-quantity-gate2-readonly-evidence",
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"product": {
|
||||
"goods_id": goods_id,
|
||||
"canonical_url": f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}",
|
||||
},
|
||||
"human_declared_state": state,
|
||||
"human_declared_quantity": quantity,
|
||||
"human_declared_selection": TARGET_SELECTION,
|
||||
"review_status": "human_review_required",
|
||||
"channel": "wifi" if ":" in serial else "usb",
|
||||
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||
"device": {
|
||||
"model": inspection.model,
|
||||
"android_version": inspection.android_version,
|
||||
"pdd_package": PDD_PACKAGE,
|
||||
"pdd_version": EXPECTED_PDD_VERSION,
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
"path": screenshot_path.name,
|
||||
"role": "quantity_state_raw_screenshot",
|
||||
"sha256": _sha256_file(screenshot_path),
|
||||
},
|
||||
{
|
||||
"path": hierarchy_path.name,
|
||||
"role": "quantity_state_raw_hierarchy_local_only",
|
||||
"sha256": _sha256_file(hierarchy_path),
|
||||
},
|
||||
{
|
||||
"path": app_path.name,
|
||||
"role": "quantity_state_app_identity",
|
||||
"sha256": _sha256_file(app_path),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
"""T-103 尺码显示动作的一次性真机证据采集。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from math import isfinite
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from time import monotonic, sleep
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
|
||||
from ..device.baseline import PDD_PACKAGE, _save_base64_screenshot, _sha256_file
|
||||
from .product_url import parse_product_url
|
||||
from .sku_selection import (
|
||||
EXPECTED_GOODS_ID,
|
||||
_parse_nodes,
|
||||
_require_color_only_panel,
|
||||
_require_safe_reveal_path,
|
||||
_revealed_unselected_projection,
|
||||
resolve_task_selection,
|
||||
SkuSelectionError,
|
||||
SkuSelectionFlow,
|
||||
)
|
||||
from .sku_selection_runner import (
|
||||
EXPECTED_ANDROID_VERSION,
|
||||
EXPECTED_DEVICE_MODEL,
|
||||
EXPECTED_SCREEN_SIZE,
|
||||
SkuSelectionRunError,
|
||||
UiautomatorSkuPanelAdapter,
|
||||
_require_expected_device,
|
||||
_require_expected_version,
|
||||
_require_screenshot_size,
|
||||
)
|
||||
|
||||
|
||||
_TARGET_URL = f"https://mobile.yangkeduo.com/goods.html?goods_id={EXPECTED_GOODS_ID}"
|
||||
_REVEAL_FAILURE_STAGES = frozenset(
|
||||
(
|
||||
"reveal_precondition",
|
||||
"reveal_attempted",
|
||||
"reveal_candidate",
|
||||
"reveal_after",
|
||||
"reveal_publish",
|
||||
)
|
||||
)
|
||||
_REVEAL_FAILURE_MARKER = object()
|
||||
|
||||
|
||||
class SkuRevealSpikeError(RuntimeError):
|
||||
"""一次性 reveal 取证未形成可发布证据。"""
|
||||
|
||||
|
||||
def _annotate_reveal_failure(error: BaseException, stage: str) -> None:
|
||||
"""只记录本模块实际走到的固定阶段;入口阶段使用独立 marker,不会被覆盖。"""
|
||||
|
||||
if type(stage) is not str or stage not in _REVEAL_FAILURE_STAGES:
|
||||
return
|
||||
try:
|
||||
setattr(error, "_cmbuyer_reveal_failure_stage", stage)
|
||||
# marker 最后写入,任一 setter 失败都不能形成可信阶段。
|
||||
setattr(error, "_cmbuyer_reveal_failure_marker", _REVEAL_FAILURE_MARKER)
|
||||
except BaseException:
|
||||
pass
|
||||
|
||||
|
||||
def safe_reveal_failure_stage(error: BaseException) -> str | None:
|
||||
"""读取可公开的 reveal 控制流阶段;伪造属性或 hostile getter 均失败闭合。"""
|
||||
|
||||
try:
|
||||
marker = getattr(error, "_cmbuyer_reveal_failure_marker", None)
|
||||
stage = getattr(error, "_cmbuyer_reveal_failure_stage", None)
|
||||
if marker is not _REVEAL_FAILURE_MARKER or type(stage) is not str:
|
||||
return None
|
||||
return stage if stage in _REVEAL_FAILURE_STAGES else None
|
||||
except BaseException:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkuRevealSpikeResult:
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
|
||||
|
||||
class SkuRevealSpikeCapturer:
|
||||
"""打开已取证面板、选一次目标颜色,再采集唯一 reveal 的前后证据。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adb_client: AdbClient,
|
||||
connector: Callable[[str], Any],
|
||||
timeout_seconds: float,
|
||||
monotonic_clock: Callable[[], float] = monotonic,
|
||||
sleep_function: Callable[[float], None] = sleep,
|
||||
) -> None:
|
||||
if not _positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._adb_client = adb_client
|
||||
self._connector = connector
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._clock = monotonic_clock
|
||||
self._sleep = sleep_function
|
||||
|
||||
def capture(
|
||||
self,
|
||||
serial: str,
|
||||
goods_id: str,
|
||||
output_directory: Path,
|
||||
) -> SkuRevealSpikeResult:
|
||||
stage = "reveal_precondition"
|
||||
staging: Path | None = None
|
||||
adapter: UiautomatorSkuPanelAdapter | None = None
|
||||
try:
|
||||
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
|
||||
raise SkuRevealSpikeError("商品不是 T-103 已取证目标,已停止取证。")
|
||||
link = parse_product_url(_TARGET_URL)
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
staging = _prepare_staging(target)
|
||||
deadline = self._clock() + self._timeout_seconds
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
_require_expected_device(inspection)
|
||||
adapter = UiautomatorSkuPanelAdapter(self._connector(serial), self._timeout_seconds)
|
||||
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
||||
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
|
||||
raise SkuRevealSpikeError("设备不是已取证的竖屏坐标空间,已停止取证。")
|
||||
pre_intent = adapter.dump_window_hierarchy()
|
||||
self._adb_client.start_pdd_view_intent(serial, link.goods_id)
|
||||
|
||||
remaining = deadline - self._clock()
|
||||
if remaining <= 0:
|
||||
raise SkuRevealSpikeError("规格入口取证超时,未执行 reveal。")
|
||||
flow = SkuSelectionFlow(
|
||||
adapter,
|
||||
entry_wait_timeout_seconds=remaining,
|
||||
monotonic_clock=self._clock,
|
||||
sleep_function=self._sleep,
|
||||
)
|
||||
flow.open_sku_panel(link.canonical_url, pre_intent)
|
||||
# spike 只复用生产 Flow 的私有前置准备,不调用完整选择流程,避免重复 reveal/M。
|
||||
flow._prepare_reveal_precondition(
|
||||
resolve_task_selection("黑色CHA(纯棉)", "M(建议100-115)")
|
||||
)
|
||||
|
||||
before_hierarchy = adapter.dump_window_hierarchy()
|
||||
before_nodes = _parse_nodes(before_hierarchy)
|
||||
_require_color_only_panel(before_nodes)
|
||||
_require_safe_reveal_path(before_nodes)
|
||||
_capture_frame(adapter, staging / "before", before_hierarchy)
|
||||
# 截图 RPC 期间页面也可能变化;真正发送手势前必须用新树再次证明同一前置与安全通道。
|
||||
before_hierarchy = adapter.dump_window_hierarchy()
|
||||
before_nodes = _parse_nodes(before_hierarchy)
|
||||
_require_color_only_panel(before_nodes)
|
||||
_require_safe_reveal_path(before_nodes)
|
||||
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
||||
current = adapter.app_current()
|
||||
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
|
||||
raise SkuRevealSpikeError("reveal 前拼多多不在前台,未执行手势。")
|
||||
(staging / "before" / "hierarchy.xml").write_text(before_hierarchy, encoding="utf-8")
|
||||
|
||||
rpc_outcome = "completed"
|
||||
# 此后即属于 attempted:adapter 会在 RPC 前封存唯一机会,结果不明也只能调和。
|
||||
stage = "reveal_attempted"
|
||||
try:
|
||||
adapter.reveal_size_options_once()
|
||||
except SkuSelectionRunError:
|
||||
rpc_outcome = "ambiguous_reconciled"
|
||||
|
||||
stage = "reveal_candidate"
|
||||
projection, after_hierarchy = self._wait_for_candidate(adapter, deadline)
|
||||
stage = "reveal_after"
|
||||
after_directory = staging / "after"
|
||||
_capture_frame(adapter, after_directory, after_hierarchy)
|
||||
reverified = adapter.dump_window_hierarchy()
|
||||
if _revealed_unselected_projection(_parse_nodes(reverified)) != projection:
|
||||
raise SkuRevealSpikeError("截图后候选状态漂移,未发布证据。")
|
||||
(after_directory / "hierarchy.xml").write_text(reverified, encoding="utf-8")
|
||||
|
||||
stage = "reveal_publish"
|
||||
manifest = _manifest(inspection, serial, rpc_outcome, staging)
|
||||
manifest_path = staging / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.rename(staging, target)
|
||||
staging = None
|
||||
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError, SkuRevealSpikeError) as error:
|
||||
_clean_staging(staging)
|
||||
failure_stage = stage
|
||||
if stage == "reveal_attempted" and (adapter is None or not adapter.reveal_attempted):
|
||||
# attempted 只能在 adapter 已于 RPC 前封存唯一机会后成立。
|
||||
failure_stage = "reveal_precondition"
|
||||
_annotate_reveal_failure(error, failure_stage)
|
||||
raise
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
mapped = SkuRevealSpikeError("规格 reveal 取证未完成,未发布本地证据目录。")
|
||||
failure_stage = stage
|
||||
if stage == "reveal_attempted" and (adapter is None or not adapter.reveal_attempted):
|
||||
failure_stage = "reveal_precondition"
|
||||
_annotate_reveal_failure(mapped, failure_stage)
|
||||
raise mapped from error
|
||||
|
||||
return SkuRevealSpikeResult(target, target / "manifest.json")
|
||||
|
||||
def _wait_for_candidate(
|
||||
self,
|
||||
adapter: UiautomatorSkuPanelAdapter,
|
||||
deadline: float,
|
||||
) -> tuple[tuple[tuple[str, ...], ...], str]:
|
||||
stable: tuple[tuple[str, ...], ...] | None = None
|
||||
while True:
|
||||
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
||||
current = adapter.app_current()
|
||||
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
|
||||
raise SkuRevealSpikeError("reveal 后拼多多不在前台,未发布证据。")
|
||||
hierarchy = adapter.dump_window_hierarchy()
|
||||
try:
|
||||
projection = _revealed_unselected_projection(_parse_nodes(hierarchy))
|
||||
except SkuSelectionError:
|
||||
projection = None
|
||||
if projection is not None and projection == stable:
|
||||
return projection, hierarchy
|
||||
stable = projection
|
||||
remaining = deadline - self._clock()
|
||||
if remaining <= 0:
|
||||
raise SkuRevealSpikeError("reveal 后未形成稳定候选状态,未发布证据。")
|
||||
self._sleep(min(0.2, remaining))
|
||||
|
||||
|
||||
def _capture_frame(adapter: UiautomatorSkuPanelAdapter, directory: Path, hierarchy: str) -> None:
|
||||
directory.mkdir()
|
||||
hierarchy_path = directory / "hierarchy.xml"
|
||||
hierarchy_path.write_text(hierarchy, encoding="utf-8")
|
||||
screenshot_path = directory / "screenshot.png"
|
||||
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
|
||||
_require_screenshot_size(screenshot_path)
|
||||
|
||||
|
||||
def _manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
rpc_outcome: str,
|
||||
staging: Path,
|
||||
) -> dict[str, Any]:
|
||||
artifacts = []
|
||||
for relative in (
|
||||
"before/screenshot.png",
|
||||
"before/hierarchy.xml",
|
||||
"after/screenshot.png",
|
||||
"after/hierarchy.xml",
|
||||
):
|
||||
path = staging / relative
|
||||
artifacts.append({"path": relative, "sha256": _sha256_file(path)})
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"operation": "t103-sku-reveal-evidence",
|
||||
"profile_id": "pdd-8.17.0-size-reveal-gap-v1",
|
||||
"product": {"goods_id": EXPECTED_GOODS_ID},
|
||||
"channel": "wifi" if ":" in serial else "usb",
|
||||
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||
"device": {
|
||||
"model": inspection.model,
|
||||
"android_version": inspection.android_version,
|
||||
"pdd_package": PDD_PACKAGE,
|
||||
"pdd_version": "8.17.0",
|
||||
},
|
||||
"reveal_attempts": 1,
|
||||
"rpc_outcome": rpc_outcome,
|
||||
"candidate_status": "human_review_required",
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
|
||||
|
||||
def _validate_new_target(target: Path) -> None:
|
||||
if target.exists() or not target.name:
|
||||
raise SkuRevealSpikeError("输出目录必须是不存在的明确新目录。")
|
||||
|
||||
|
||||
def _prepare_staging(target: Path) -> Path:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
||||
staging.mkdir()
|
||||
return staging
|
||||
|
||||
|
||||
def _clean_staging(staging: Path | None) -> None:
|
||||
if staging is not None and staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
|
||||
|
||||
def _positive_finite(value: object) -> bool:
|
||||
return (
|
||||
isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and value > 0
|
||||
and isfinite(value)
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,16 +21,31 @@ from adbutils.errors import AdbTimeout
|
||||
from uiautomator2.exceptions import HTTPTimeoutError
|
||||
|
||||
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
|
||||
from ..device.baseline import PDD_PACKAGE, SCREENSHOT_PARAMS, _save_base64_screenshot, _sha256_file
|
||||
from ..device.baseline import (
|
||||
PDD_PACKAGE,
|
||||
SCREENSHOT_PARAMS,
|
||||
_save_base64_screenshot,
|
||||
_sha256_file,
|
||||
_validate_hierarchy,
|
||||
)
|
||||
from .product_open import EXPECTED_PDD_VERSION
|
||||
from .product_url import ProductUrl, parse_product_url
|
||||
from .product_url import ProductUrl, ProductUrlError, parse_product_url
|
||||
from .sku_selection import (
|
||||
EXPECTED_GOODS_ID,
|
||||
EXPECTED_UNIT_PRICE,
|
||||
SkuPanelDevice,
|
||||
SkuSelectionError,
|
||||
SkuSelectionFlow,
|
||||
_SKU_ENTRY_FAILURE_STAGES,
|
||||
_REVEAL_GESTURE,
|
||||
_ENTRY_TEXT_BOUNDS,
|
||||
_TARGET_COLOR_UNROLLED_BOUNDS,
|
||||
_TARGET_SIZE_BOUNDS,
|
||||
_action_bounds,
|
||||
_annotate_sku_entry_failure,
|
||||
_parse_nodes,
|
||||
_safe_sku_entry_failure_stage,
|
||||
_unit_price,
|
||||
resolve_task_selection,
|
||||
)
|
||||
|
||||
@@ -39,6 +54,25 @@ EXPECTED_DEVICE_MODEL = "PKG110"
|
||||
EXPECTED_ANDROID_VERSION = "16"
|
||||
EXPECTED_SCREEN_SIZE = (1080, 2376)
|
||||
|
||||
# CLI 只允许输出这些固定阶段码。阶段码描述运行器自己的控制流,不包含页面
|
||||
# 文本、节点属性、serial、路径或第三方异常;未知/伪造值统一降级为 unknown。
|
||||
_FAILURE_STAGES = frozenset(
|
||||
(
|
||||
"precheck",
|
||||
"device_inspection",
|
||||
"device_session",
|
||||
"product_open",
|
||||
"sku_entry",
|
||||
*_SKU_ENTRY_FAILURE_STAGES,
|
||||
"sku_selection",
|
||||
"price_verification",
|
||||
"screenshot_capture",
|
||||
"screenshot_reverify",
|
||||
"safe_exit",
|
||||
"publish",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class SkuSelectionRunError(RuntimeError):
|
||||
"""T-103 运行未完整完成;错误文本不携带设备或页面原文。"""
|
||||
@@ -60,6 +94,52 @@ class SkuSelectionDeviceAdapterError(SkuSelectionRunError):
|
||||
"""第三方设备接口失败的脱敏映射。"""
|
||||
|
||||
|
||||
class SkuExitSpikeError(SkuSelectionRunError):
|
||||
"""T-104 阶段 A 未形成完整的本机退出证据。"""
|
||||
|
||||
|
||||
def safe_failure_stage(error: BaseException) -> str:
|
||||
"""返回允许公开的固定阶段码,绝不回显异常正文。"""
|
||||
|
||||
try:
|
||||
stage = getattr(error, "_cmbuyer_failure_stage", None)
|
||||
# exact str 避免恶意 str 子类在 hash/eq 中执行任意异常;诊断路径
|
||||
# 自己也必须失败闭合,不能让异常正文越过 CLI 的统一脱敏出口。
|
||||
if type(stage) is not str or stage not in _FAILURE_STAGES:
|
||||
return "unknown"
|
||||
if stage in _SKU_ENTRY_FAILURE_STAGES:
|
||||
return stage if _safe_sku_entry_failure_stage(error) == stage else "unknown"
|
||||
return stage
|
||||
except BaseException:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _annotate_failure(error: BaseException, stage: str) -> None:
|
||||
"""只给本次异常附加白名单控制流事实;原异常文本仍不对外输出。"""
|
||||
|
||||
safe_stage = stage if stage in _FAILURE_STAGES else "unknown"
|
||||
try:
|
||||
setattr(error, "_cmbuyer_failure_stage", safe_stage)
|
||||
except BaseException:
|
||||
# 极端第三方异常不允许写属性时仍保持原失败闭合语义。
|
||||
pass
|
||||
|
||||
|
||||
def _failure_stage_for(error: BaseException, runner_stage: str) -> str:
|
||||
if runner_stage == "sku_entry":
|
||||
flow_stage = _safe_sku_entry_failure_stage(error)
|
||||
if flow_stage is not None:
|
||||
return flow_stage
|
||||
return runner_stage
|
||||
|
||||
|
||||
def _annotate_mapped_failure(mapped: BaseException, source: BaseException, runner_stage: str) -> None:
|
||||
stage = _failure_stage_for(source, runner_stage)
|
||||
if stage in _SKU_ENTRY_FAILURE_STAGES:
|
||||
_annotate_sku_entry_failure(mapped, stage)
|
||||
_annotate_failure(mapped, stage)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkuSelectionRunResult:
|
||||
"""已发布的截图和无页面正文 manifest 摘要。"""
|
||||
@@ -68,12 +148,21 @@ class SkuSelectionRunResult:
|
||||
screenshot_path: Path
|
||||
manifest_path: Path
|
||||
unit_price: str
|
||||
captured_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkuExitSpikeResult:
|
||||
"""已原子发布、仍需人工判断页面身份的 T-104 证据。"""
|
||||
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
|
||||
|
||||
class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||
"""把 uiautomator2 缩为 T-103 所需的读取与三种命名操作。
|
||||
"""把 uiautomator2 缩为 T-103 所需的读取与四种命名操作。
|
||||
|
||||
``tap_sku_entry``、``tap_sku_option`` 和 ``leave_sku_panel`` 是仅有的状态改变方法;
|
||||
四个命名方法是仅有的状态改变入口;reveal 的手势参数固定且不向 Flow 暴露;
|
||||
坐标由 Flow 和本类双重检查后才计算中心点,每次调用只执行一次底层动作。
|
||||
"""
|
||||
|
||||
@@ -83,7 +172,14 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||
self._device = device
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._entry_was_tapped = False
|
||||
self._entry_bounds_attempted: str | None = None
|
||||
self._entry_rpc_outcome = "not_attempted"
|
||||
self._option_bounds_attempted: set[str] = set()
|
||||
self._option_rpc_outcomes: dict[str, str] = {}
|
||||
self._reveal_attempted = False
|
||||
self._reveal_rpc_outcome = "not_attempted"
|
||||
self._left_panel = False
|
||||
self._back_rpc_outcome = "not_attempted"
|
||||
|
||||
@property
|
||||
def entry_was_tapped(self) -> bool:
|
||||
@@ -95,6 +191,38 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||
def left_panel(self) -> bool:
|
||||
return self._left_panel
|
||||
|
||||
@property
|
||||
def reveal_attempted(self) -> bool:
|
||||
return self._reveal_attempted
|
||||
|
||||
@property
|
||||
def entry_rpc_outcome(self) -> str:
|
||||
return self._entry_rpc_outcome
|
||||
|
||||
@property
|
||||
def entry_bounds_attempted(self) -> str | None:
|
||||
return self._entry_bounds_attempted
|
||||
|
||||
@property
|
||||
def option_rpc_outcomes(self) -> tuple[tuple[str, str], ...]:
|
||||
return tuple(sorted(self._option_rpc_outcomes.items()))
|
||||
|
||||
@property
|
||||
def reveal_rpc_outcome(self) -> str:
|
||||
return self._reveal_rpc_outcome
|
||||
|
||||
@property
|
||||
def option_attempts(self) -> int:
|
||||
return len(self._option_bounds_attempted)
|
||||
|
||||
@property
|
||||
def back_attempts(self) -> int:
|
||||
return int(self._left_panel)
|
||||
|
||||
@property
|
||||
def back_rpc_outcome(self) -> str:
|
||||
return self._back_rpc_outcome
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]:
|
||||
value = self._call("app_info", package_name)
|
||||
if not isinstance(value, dict):
|
||||
@@ -114,19 +242,46 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||
return value
|
||||
|
||||
def tap_sku_entry(self, bounds: str) -> None:
|
||||
if self._entry_was_tapped:
|
||||
raise SkuSelectionDeviceAdapterError("规格入口已经尝试过,拒绝重试。")
|
||||
# 超时也可能表示底层事件已经送达;必须先封存 attempt,后续绝不重试该入口。
|
||||
self._entry_was_tapped = True
|
||||
self._entry_bounds_attempted = bounds
|
||||
self._entry_rpc_outcome = "ambiguous"
|
||||
self._tap_bounds_once(bounds)
|
||||
self._entry_rpc_outcome = "completed"
|
||||
|
||||
def tap_sku_option(self, bounds: str) -> None:
|
||||
if bounds in self._option_bounds_attempted:
|
||||
raise SkuSelectionDeviceAdapterError("同一规格选项已经尝试过,拒绝重试。")
|
||||
# 规格 RPC 也可能送达后超时;按 exact bounds 封存本次唯一机会。
|
||||
self._option_bounds_attempted.add(bounds)
|
||||
self._option_rpc_outcomes[bounds] = "ambiguous"
|
||||
self._tap_bounds_once(bounds)
|
||||
self._option_rpc_outcomes[bounds] = "completed"
|
||||
|
||||
def reveal_size_options_once(self) -> None:
|
||||
if self._reveal_attempted:
|
||||
raise SkuSelectionDeviceAdapterError("规格显示动作已经尝试过,拒绝重试。")
|
||||
# 手势唯一机会在 RPC 前封存;生产 API 不接受方向、坐标或步数参数。
|
||||
self._reveal_attempted = True
|
||||
self._reveal_rpc_outcome = "ambiguous"
|
||||
self._call(
|
||||
"jsonrpc_call",
|
||||
"swipe",
|
||||
list(_REVEAL_GESTURE),
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
self._reveal_rpc_outcome = "completed"
|
||||
|
||||
def leave_sku_panel(self) -> None:
|
||||
if self._left_panel:
|
||||
raise SkuSelectionDeviceAdapterError("规格面板已经执行过返回,已停止操作。")
|
||||
# 底层调用即使报错也可能已把返回事件送达;先封存本次机会,finally 不得再次返回。
|
||||
self._left_panel = True
|
||||
self._back_rpc_outcome = "ambiguous"
|
||||
self._call("jsonrpc_call", "pressKey", ["back"], timeout=self._timeout_seconds)
|
||||
self._back_rpc_outcome = "completed"
|
||||
|
||||
def capture_screenshot(self) -> str:
|
||||
value = self._call("jsonrpc_call", "takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds)
|
||||
@@ -158,6 +313,210 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||
raise SkuSelectionDeviceAdapterError("规格面板设备操作失败,已停止操作。") from error
|
||||
|
||||
|
||||
class UiautomatorSkuExitAdapter:
|
||||
"""T-104 阶段 A 的窄设备边界:只读能力加唯一一次命名 Back。
|
||||
|
||||
取证脚本不能取得 T-103 的入口、规格选项或 reveal 方法。Back 的唯一机会在
|
||||
JSON-RPC 前封存,因为超时无法证明事件没有送达,任何结果不明都不得重发。
|
||||
"""
|
||||
|
||||
def __init__(self, device: Any, timeout_seconds: float) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._device = device
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._back_attempted = False
|
||||
self._back_rpc_outcome = "not_attempted"
|
||||
|
||||
@property
|
||||
def back_attempts(self) -> int:
|
||||
return int(self._back_attempted)
|
||||
|
||||
@property
|
||||
def back_rpc_outcome(self) -> str:
|
||||
return self._back_rpc_outcome
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]:
|
||||
value = self._call("app_info", package_name)
|
||||
if not isinstance(value, dict):
|
||||
raise SkuSelectionDeviceAdapterError("无法读取应用版本,已停止取证。")
|
||||
return value
|
||||
|
||||
def app_current(self) -> dict[str, Any]:
|
||||
value = self._call("app_current")
|
||||
if not isinstance(value, dict):
|
||||
raise SkuSelectionDeviceAdapterError("无法读取前台应用,已停止取证。")
|
||||
return value
|
||||
|
||||
def dump_window_hierarchy(self) -> str:
|
||||
value = self._call(
|
||||
"jsonrpc_call",
|
||||
"dumpWindowHierarchy",
|
||||
[False, 50],
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
if not isinstance(value, str):
|
||||
raise SkuSelectionDeviceAdapterError("节点树读取失败,已停止取证。")
|
||||
return value
|
||||
|
||||
def capture_screenshot(self) -> str:
|
||||
value = self._call(
|
||||
"jsonrpc_call",
|
||||
"takeScreenshot",
|
||||
SCREENSHOT_PARAMS,
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
if not isinstance(value, str):
|
||||
raise SkuSelectionScreenshotError("退出后截图读取失败,未发布任何证据产物。")
|
||||
return value
|
||||
|
||||
def display_size(self) -> tuple[int, int]:
|
||||
value = self._call("window_size")
|
||||
if (
|
||||
not isinstance(value, tuple)
|
||||
or len(value) != 2
|
||||
or any(not isinstance(item, int) for item in value)
|
||||
):
|
||||
raise SkuSelectionDeviceAdapterError("无法读取屏幕坐标空间,已停止取证。")
|
||||
return value
|
||||
|
||||
def leave_sku_panel(self) -> None:
|
||||
if self._back_attempted:
|
||||
raise SkuSelectionDeviceAdapterError("本次取证已经尝试过返回,拒绝重试。")
|
||||
self._back_attempted = True
|
||||
self._back_rpc_outcome = "ambiguous"
|
||||
self._call(
|
||||
"jsonrpc_call",
|
||||
"pressKey",
|
||||
["back"],
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
self._back_rpc_outcome = "completed"
|
||||
|
||||
def _call(self, method: str, *args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
operation = getattr(self._device, method)
|
||||
return operation(*args, **kwargs)
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
raise SkuSelectionRunTimeoutError("T-104 设备操作超时,已停止操作。") from error
|
||||
except SkuSelectionRunError:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise SkuSelectionDeviceAdapterError("T-104 设备操作失败,已停止操作。") from error
|
||||
|
||||
|
||||
class SkuExitSpikeCapturer:
|
||||
"""从人工停驻的已验证目标面板执行一次 Back,再只读采集阶段 A 证据。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adb_client: AdbClient,
|
||||
connector: Callable[[str], Any],
|
||||
timeout_seconds: float,
|
||||
) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._adb_client = adb_client
|
||||
self._connector = connector
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._started = False
|
||||
|
||||
def capture(self, serial: str, output_directory: Path) -> SkuExitSpikeResult:
|
||||
if self._started:
|
||||
raise SkuExitSpikeError("同一取证器不可重复调用。")
|
||||
self._started = True
|
||||
|
||||
staging: Path | None = None
|
||||
adapter: UiautomatorSkuExitAdapter | None = None
|
||||
try:
|
||||
if type(serial) is not str or not serial.strip():
|
||||
raise SkuExitSpikeError("必须显式提供非空设备通道。")
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
staging = _prepare_staging(target)
|
||||
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
_require_expected_device(inspection)
|
||||
adapter = UiautomatorSkuExitAdapter(
|
||||
self._connector(serial),
|
||||
self._timeout_seconds,
|
||||
)
|
||||
|
||||
# 第一次只读核验拒绝把任意页面带入 Back 边界;第二次紧邻 Back,覆盖核验期间漂移。
|
||||
_require_t104_exit_precondition(adapter)
|
||||
_require_t104_exit_precondition(adapter)
|
||||
|
||||
rpc_outcome = "completed"
|
||||
try:
|
||||
adapter.leave_sku_panel()
|
||||
except SkuSelectionRunError:
|
||||
if adapter.back_attempts != 1 or adapter.back_rpc_outcome != "ambiguous":
|
||||
raise
|
||||
# RPC 失败不能证明 Back 未送达。只读采集可供人调和,但绝不再发第二次动作。
|
||||
rpc_outcome = "ambiguous_reconciled"
|
||||
|
||||
if adapter.back_attempts != 1:
|
||||
raise SkuExitSpikeError("返回动作审计不完整,未发布任何证据产物。")
|
||||
if rpc_outcome == "completed" and adapter.back_rpc_outcome != "completed":
|
||||
raise SkuExitSpikeError("返回动作结果不完整,未发布任何证据产物。")
|
||||
|
||||
# Back 后先确认仍是已取证版本的 PDD;不能先把其他前台应用写进本地证据。
|
||||
initial_app = _require_t104_post_app(adapter)
|
||||
|
||||
screenshot_path = staging / "post_exit_screenshot.png"
|
||||
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
|
||||
_require_screenshot_size(screenshot_path)
|
||||
|
||||
hierarchy = adapter.dump_window_hierarchy()
|
||||
_validate_hierarchy(hierarchy)
|
||||
hierarchy_path = staging / "post_exit_hierarchy.xml"
|
||||
hierarchy_path.write_text(hierarchy, encoding="utf-8")
|
||||
|
||||
current_app = _require_t104_post_app(adapter)
|
||||
if current_app != initial_app:
|
||||
raise SkuExitSpikeError("退出后应用摘要在采集期间漂移,未发布任何证据产物。")
|
||||
app_path = staging / "post_exit_app.json"
|
||||
app_path.write_text(
|
||||
json.dumps(current_app, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
manifest_path = staging / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
_t104_exit_manifest(
|
||||
inspection,
|
||||
serial,
|
||||
rpc_outcome,
|
||||
screenshot_path,
|
||||
hierarchy_path,
|
||||
app_path,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.rename(staging, target)
|
||||
staging = None
|
||||
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError):
|
||||
_clean_staging(staging)
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuExitSpikeError("T-104 取证超时,未发布任何证据产物。") from error
|
||||
except OSError as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuExitSpikeError("T-104 证据目录无法发布,未发布任何证据产物。") from error
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuExitSpikeError("T-104 取证未完成,未发布任何证据产物。") from error
|
||||
|
||||
return SkuExitSpikeResult(target, target / "manifest.json")
|
||||
|
||||
|
||||
class SkuSelectionRunner:
|
||||
"""只运行 T-103 目标规格恢复、价格确认、原始截图和一次安全退出。"""
|
||||
|
||||
@@ -183,41 +542,57 @@ class SkuSelectionRunner:
|
||||
task_size: str,
|
||||
output_directory: Path,
|
||||
) -> SkuSelectionRunResult:
|
||||
link = parse_product_url(product_url)
|
||||
if link.goods_id != EXPECTED_GOODS_ID:
|
||||
raise SkuSelectionRunError("商品不是 T-103 已取证目标,已停止操作。")
|
||||
selection = resolve_task_selection(task_color, task_size)
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
|
||||
stage = "precheck"
|
||||
staging: Path | None = None
|
||||
adapter: UiautomatorSkuPanelAdapter | None = None
|
||||
flow: SkuSelectionFlow | None = None
|
||||
staging = _prepare_staging(target)
|
||||
deadline = self._monotonic_clock() + self._timeout_seconds
|
||||
try:
|
||||
link = parse_product_url(product_url)
|
||||
if link.goods_id != EXPECTED_GOODS_ID:
|
||||
raise SkuSelectionRunError("商品不是 T-103 已取证目标,已停止操作。")
|
||||
selection = resolve_task_selection(task_color, task_size)
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
staging = _prepare_staging(target)
|
||||
deadline = self._monotonic_clock() + self._timeout_seconds
|
||||
|
||||
stage = "device_inspection"
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
_require_expected_device(inspection)
|
||||
|
||||
stage = "device_session"
|
||||
adapter = UiautomatorSkuPanelAdapter(self._connector(serial), self._timeout_seconds)
|
||||
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
||||
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
|
||||
raise SkuSelectionRunError("设备不是已取证的竖屏坐标空间,已停止操作。")
|
||||
pre_intent_hierarchy = adapter.dump_window_hierarchy()
|
||||
|
||||
# 固定 ACTION_VIEW、固定 PDD package 和 canonical goods_id;不接受任意 URL 或 shell。
|
||||
stage = "product_open"
|
||||
self._adb_client.start_pdd_view_intent(serial, link.goods_id)
|
||||
|
||||
remaining = deadline - self._monotonic_clock()
|
||||
if remaining <= 0:
|
||||
raise SkuSelectionRunTimeoutError("等待规格入口超时,未执行点击。")
|
||||
|
||||
stage = "sku_entry"
|
||||
flow = SkuSelectionFlow(adapter, entry_wait_timeout_seconds=remaining)
|
||||
flow.open_sku_panel(link.canonical_url, pre_intent_hierarchy)
|
||||
|
||||
stage = "sku_selection"
|
||||
flow.select_sku_options(selection)
|
||||
|
||||
stage = "price_verification"
|
||||
unit_price = flow.verify_target_selection_and_read_price(selection)
|
||||
if unit_price != EXPECTED_UNIT_PRICE:
|
||||
raise SkuSelectionUnexpectedPriceError("规格面板现价不是本任务已确认值,已停止操作。")
|
||||
|
||||
stage = "screenshot_capture"
|
||||
screenshot_path = staging / "screenshot.png"
|
||||
try:
|
||||
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
|
||||
screenshot_payload = adapter.capture_screenshot()
|
||||
captured_at = datetime.now(UTC)
|
||||
_save_base64_screenshot(screenshot_payload, screenshot_path)
|
||||
_require_screenshot_size(screenshot_path)
|
||||
except SkuSelectionRunError:
|
||||
raise
|
||||
@@ -226,36 +601,63 @@ class SkuSelectionRunner:
|
||||
|
||||
manifest_path = staging / "manifest.json"
|
||||
# 截图可能落在动态页面切换边界;发布前必须用一棵更新节点树同时重证两维和现价。
|
||||
stage = "screenshot_reverify"
|
||||
final_price = flow.verify_target_selection_and_read_price(selection)
|
||||
if final_price != EXPECTED_UNIT_PRICE:
|
||||
raise SkuSelectionUnexpectedPriceError("截图后规格面板现价不是本任务已确认值,已停止操作。")
|
||||
# 正常路径仍经 Flow 做最后一次前台和面板判定;返回操作只发生一次。
|
||||
stage = "safe_exit"
|
||||
flow.exit_sku_panel_safely()
|
||||
|
||||
_require_completed_action_audit(adapter)
|
||||
|
||||
stage = "publish"
|
||||
manifest_path.write_text(
|
||||
json.dumps(_manifest(inspection, serial, link, screenshot_path, task_color, task_size), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
json.dumps(
|
||||
_manifest(
|
||||
inspection,
|
||||
serial,
|
||||
link,
|
||||
screenshot_path,
|
||||
task_color,
|
||||
task_size,
|
||||
adapter,
|
||||
captured_at,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。
|
||||
os.rename(staging, target)
|
||||
staging = None
|
||||
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError):
|
||||
except (DeviceConnectionError, ProductUrlError, SkuSelectionRunError, SkuSelectionError) as error:
|
||||
_clean_staging(staging)
|
||||
_annotate_failure(error, _failure_stage_for(error, stage))
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。") from error
|
||||
mapped = SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。")
|
||||
_annotate_mapped_failure(mapped, error, stage)
|
||||
raise mapped from error
|
||||
except OSError as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。") from error
|
||||
mapped = SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。")
|
||||
_annotate_mapped_failure(mapped, error, stage)
|
||||
raise mapped from error
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。") from error
|
||||
mapped = SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。")
|
||||
_annotate_mapped_failure(mapped, error, stage)
|
||||
raise mapped from error
|
||||
finally:
|
||||
# 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。
|
||||
if flow is not None and adapter is not None and adapter.entry_was_tapped and not adapter.left_panel:
|
||||
# 结果不明只允许读回 pending;失败路径绝不继续后续动作或自动 Back。
|
||||
if flow is not None:
|
||||
try:
|
||||
flow.reconcile_pending_action()
|
||||
flow.exit_sku_panel_safely()
|
||||
except (SkuSelectionRunError, SkuSelectionError):
|
||||
pass
|
||||
|
||||
@@ -264,6 +666,7 @@ class SkuSelectionRunner:
|
||||
screenshot_path=target / "screenshot.png",
|
||||
manifest_path=target / "manifest.json",
|
||||
unit_price=EXPECTED_UNIT_PRICE,
|
||||
captured_at=captured_at,
|
||||
)
|
||||
|
||||
|
||||
@@ -310,6 +713,87 @@ def _require_expected_device(inspection: DeviceInspection) -> None:
|
||||
raise SkuSelectionRunError("设备型号或 Android 版本不是已取证组合,已停止操作。")
|
||||
|
||||
|
||||
def _require_t104_exit_precondition(adapter: UiautomatorSkuExitAdapter) -> None:
|
||||
"""fresh 核验 T-103 已人验的唯一 Back 前置,不产生任何页面动作。"""
|
||||
|
||||
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
||||
current = adapter.app_current()
|
||||
if current.get("package") != PDD_PACKAGE:
|
||||
raise SkuExitSpikeError("Back 前拼多多不在前台,已停止取证。")
|
||||
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
|
||||
raise SkuExitSpikeError("屏幕坐标空间不是已取证尺寸,已停止取证。")
|
||||
if _unit_price(_parse_nodes(adapter.dump_window_hierarchy())) != EXPECTED_UNIT_PRICE:
|
||||
raise SkuExitSpikeError("Back 前目标规格或当前价不匹配,已停止取证。")
|
||||
|
||||
|
||||
def _post_exit_app_evidence(value: object) -> dict[str, str]:
|
||||
"""只保留页面身份复核需要的应用字段,不把第三方返回整体写盘。"""
|
||||
|
||||
if not isinstance(value, dict):
|
||||
raise SkuExitSpikeError("退出后应用摘要无效,未发布任何证据产物。")
|
||||
package = value.get("package")
|
||||
activity = value.get("activity")
|
||||
if not isinstance(package, str) or not package.strip():
|
||||
raise SkuExitSpikeError("退出后应用包名缺失,未发布任何证据产物。")
|
||||
if not isinstance(activity, str) or not activity.strip():
|
||||
raise SkuExitSpikeError("退出后 Activity 缺失,未发布任何证据产物。")
|
||||
return {"package": package, "activity": activity}
|
||||
|
||||
|
||||
def _require_t104_post_app(adapter: UiautomatorSkuExitAdapter) -> dict[str, str]:
|
||||
current = _post_exit_app_evidence(adapter.app_current())
|
||||
if current["package"] != PDD_PACKAGE:
|
||||
raise SkuExitSpikeError("Back 后拼多多不在前台,未采集页面证据。")
|
||||
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
||||
return current
|
||||
|
||||
|
||||
def _t104_exit_manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
rpc_outcome: str,
|
||||
screenshot_path: Path,
|
||||
hierarchy_path: Path,
|
||||
app_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
"""阶段 A 只写事实与哈希;不声明退出成功,也不授予价格证据角色。"""
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"operation": "t104-sku-exit-evidence",
|
||||
"product": {"goods_id": EXPECTED_GOODS_ID},
|
||||
"channel": "wifi" if ":" in serial else "usb",
|
||||
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||
"device": {
|
||||
"model": inspection.model,
|
||||
"android_version": inspection.android_version,
|
||||
"pdd_package": PDD_PACKAGE,
|
||||
"pdd_version": EXPECTED_PDD_VERSION,
|
||||
},
|
||||
"back_attempts": 1,
|
||||
"rpc_outcome": rpc_outcome,
|
||||
"post_exit_status": "human_review_required",
|
||||
"artifacts": [
|
||||
{
|
||||
"path": screenshot_path.name,
|
||||
"role": "post_exit_human_review_only",
|
||||
"sha256": _sha256_file(screenshot_path),
|
||||
},
|
||||
{
|
||||
"path": hierarchy_path.name,
|
||||
"role": "post_exit_raw_hierarchy_local_only",
|
||||
"sha256": _sha256_file(hierarchy_path),
|
||||
},
|
||||
{
|
||||
"path": app_path.name,
|
||||
"role": "post_exit_app_identity_human_review_only",
|
||||
"sha256": _sha256_file(app_path),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _require_screenshot_size(screenshot_path: Path) -> None:
|
||||
try:
|
||||
with Image.open(screenshot_path) as image:
|
||||
@@ -322,20 +806,83 @@ def _require_screenshot_size(screenshot_path: Path) -> None:
|
||||
raise SkuSelectionScreenshotError("原始截图无效,未发布任何证据产物。") from error
|
||||
|
||||
|
||||
def _manifest(inspection: DeviceInspection, serial: str, link: ProductUrl, screenshot_path: Path, task_color: str, task_size: str) -> dict[str, Any]:
|
||||
def _require_completed_action_audit(adapter: UiautomatorSkuPanelAdapter) -> None:
|
||||
"""发布只接受完整正常链;测试替身或缺动作结果不能伪造真机闭环。"""
|
||||
|
||||
if (
|
||||
not adapter.entry_was_tapped
|
||||
or adapter.entry_bounds_attempted != _ENTRY_TEXT_BOUNDS
|
||||
or adapter.entry_rpc_outcome != "completed"
|
||||
or adapter.option_rpc_outcomes
|
||||
!= tuple(
|
||||
sorted(
|
||||
(
|
||||
(_TARGET_COLOR_UNROLLED_BOUNDS, "completed"),
|
||||
(_TARGET_SIZE_BOUNDS, "completed"),
|
||||
)
|
||||
)
|
||||
)
|
||||
or not adapter.reveal_attempted
|
||||
or adapter.reveal_rpc_outcome != "completed"
|
||||
or adapter.back_attempts != 1
|
||||
or adapter.back_rpc_outcome != "completed"
|
||||
):
|
||||
raise SkuSelectionRunError("规格动作审计链不完整,未发布任何证据产物。")
|
||||
|
||||
|
||||
def _manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
link: ProductUrl,
|
||||
screenshot_path: Path,
|
||||
task_color: str,
|
||||
task_size: str,
|
||||
adapter: UiautomatorSkuPanelAdapter,
|
||||
captured_at: datetime,
|
||||
) -> dict[str, Any]:
|
||||
"""仅写可审计摘要;原始 serial、节点树、页面文案和实际截图内容均不写入 manifest。"""
|
||||
|
||||
option_outcomes = dict(adapter.option_rpc_outcomes)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"captured_at": captured_at.isoformat(),
|
||||
"operation": "t103-sku-selection",
|
||||
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
|
||||
"target_selection": {"color": task_color, "size": task_size},
|
||||
"unit_price": EXPECTED_UNIT_PRICE,
|
||||
"selection_status": "restored",
|
||||
"panel_status": "verified",
|
||||
"panel_status": "verified_before_back",
|
||||
"back_attempts": adapter.back_attempts,
|
||||
"back_rpc_outcome": adapter.back_rpc_outcome,
|
||||
"actions": {
|
||||
"sku_entry": {
|
||||
"attempts": int(adapter.entry_was_tapped),
|
||||
"rpc_outcome": adapter.entry_rpc_outcome,
|
||||
},
|
||||
"target_color": {
|
||||
"attempts": int(_TARGET_COLOR_UNROLLED_BOUNDS in option_outcomes),
|
||||
"rpc_outcome": option_outcomes.get(
|
||||
_TARGET_COLOR_UNROLLED_BOUNDS, "not_attempted"
|
||||
),
|
||||
},
|
||||
"size_reveal": {
|
||||
"attempts": int(adapter.reveal_attempted),
|
||||
"rpc_outcome": adapter.reveal_rpc_outcome,
|
||||
},
|
||||
"target_size": {
|
||||
"attempts": int(_TARGET_SIZE_BOUNDS in option_outcomes),
|
||||
"rpc_outcome": option_outcomes.get(
|
||||
_TARGET_SIZE_BOUNDS, "not_attempted"
|
||||
),
|
||||
},
|
||||
"back": {
|
||||
"attempts": adapter.back_attempts,
|
||||
"rpc_outcome": adapter.back_rpc_outcome,
|
||||
},
|
||||
},
|
||||
"post_exit_status": "same_product_verified",
|
||||
"safe_exit": "completed",
|
||||
"page_identity": "human_review_required",
|
||||
"page_identity": "same_goods_evidence_bound",
|
||||
"channel": "wifi" if ":" in serial else "usb",
|
||||
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||
"device": {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""采购工具轮询会话协调器。"""
|
||||
|
||||
from .coordinator import ClaimedTaskView, PollingCoordinator, PollingState, RecoveryStatus, StartReadiness
|
||||
|
||||
__all__ = ["ClaimedTaskView", "PollingCoordinator", "PollingState", "RecoveryStatus", "StartReadiness"]
|
||||
@@ -0,0 +1,589 @@
|
||||
"""在 Qt 事件循环中协调可恢复的领取会话。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Protocol
|
||||
|
||||
from PySide6.QtCore import QObject, QThread, QTimer, Signal, Slot
|
||||
|
||||
from cmbuyer_client.core.errors import (
|
||||
AmbiguousRemoteError,
|
||||
ClientError,
|
||||
CredentialRemoteError,
|
||||
ManualRemoteError,
|
||||
ProtocolRemoteError,
|
||||
StateError,
|
||||
)
|
||||
from cmbuyer_client.core.models import ClaimedTask
|
||||
from cmbuyer_client.localstate.models import PollingSession, ProfileSettings, RecoverySnapshot
|
||||
from cmbuyer_client.logging_policy import redact_text
|
||||
|
||||
|
||||
SAFE_AMBIGUOUS_REASONS = frozenset(
|
||||
("http_result_unknown", "server_result_unknown", "truncated_response")
|
||||
)
|
||||
|
||||
|
||||
class PollingState(str, Enum):
|
||||
STOPPED = "STOPPED"
|
||||
STARTING = "STARTING"
|
||||
BLOCKED = "BLOCKED"
|
||||
RECOVERING = "RECOVERING"
|
||||
WAITING = "WAITING"
|
||||
CLAIMING = "CLAIMING"
|
||||
ACTIVE = "ACTIVE"
|
||||
RECOVERY_REQUIRED = "RECOVERY_REQUIRED"
|
||||
|
||||
|
||||
class PollingStore(Protocol):
|
||||
def recovery_snapshot(self, profile_id: str) -> RecoverySnapshot: ...
|
||||
|
||||
def start_or_resume_polling(self, profile_id: str) -> PollingSession: ...
|
||||
|
||||
def request_stop(self, profile_id: str) -> PollingSession: ...
|
||||
|
||||
|
||||
class ClaimGateway(Protocol):
|
||||
def claim_next(self, profile_id: str) -> ClaimedTask | None: ...
|
||||
|
||||
|
||||
class ExecutionConsumer(Protocol):
|
||||
def accept_claim(self, claimed: ClaimedTask, profile: ProfileSettings) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StartReadiness:
|
||||
"""由后续已取证执行能力注入;T-304 自己不探测网络或设备。"""
|
||||
|
||||
ready: bool
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaimedTaskView:
|
||||
"""允许发往 UI 的最小投影,刻意不包含 authorization/claim token。"""
|
||||
|
||||
task_id: str
|
||||
title: str
|
||||
status: str = "已领取"
|
||||
|
||||
@classmethod
|
||||
def from_claim(cls, claimed: ClaimedTask) -> "ClaimedTaskView":
|
||||
return cls(task_id=claimed.task.id, title=redact_text(claimed.task.title))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoveryStatus:
|
||||
"""可进入 UI 的恢复摘要;不携带 task snapshot、claim token 或密文。"""
|
||||
|
||||
has_open_session: bool
|
||||
session_accept_new: bool
|
||||
has_pending_claim: bool
|
||||
has_active_claim: bool
|
||||
has_pending_active_work: bool
|
||||
|
||||
@classmethod
|
||||
def from_snapshot(cls, snapshot: RecoverySnapshot) -> "RecoveryStatus":
|
||||
return cls(
|
||||
has_open_session=snapshot.session is not None,
|
||||
session_accept_new=bool(snapshot.session and snapshot.session.accept_new),
|
||||
has_pending_claim=snapshot.pending_claim is not None,
|
||||
has_active_claim=snapshot.active_claim is not None,
|
||||
has_pending_active_work=bool(snapshot.pending_renew or snapshot.pending_evidence),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _BootstrapResult:
|
||||
recovery: RecoveryStatus
|
||||
session: PollingSession | None
|
||||
normalized_stop: PollingSession | None = None
|
||||
|
||||
|
||||
class _PollingWorker(QObject):
|
||||
bootstrap_finished = Signal(object)
|
||||
claim_finished = Signal(object)
|
||||
stop_finished = Signal(object)
|
||||
failed = Signal(str, object)
|
||||
|
||||
def __init__(self, store: PollingStore) -> None:
|
||||
super().__init__()
|
||||
self._store = store
|
||||
self._gateway: ClaimGateway | None = None
|
||||
|
||||
@Slot(object)
|
||||
def configure_gateway(self, gateway: object) -> None:
|
||||
if not hasattr(gateway, "claim_next"):
|
||||
self.failed.emit("configure", RuntimeError("invalid_claim_gateway"))
|
||||
return
|
||||
self._gateway = gateway # type: ignore[assignment]
|
||||
|
||||
@Slot(str)
|
||||
def bootstrap(self, profile_id: str) -> None:
|
||||
try:
|
||||
snapshot = self._store.recovery_snapshot(profile_id)
|
||||
recovery = RecoveryStatus.from_snapshot(snapshot)
|
||||
if snapshot.session is not None and snapshot.session.accept_new:
|
||||
stopped = self._store.request_stop(profile_id)
|
||||
self.bootstrap_finished.emit(_BootstrapResult(recovery, None, stopped))
|
||||
return
|
||||
if snapshot.active_claim is not None:
|
||||
self.bootstrap_finished.emit(_BootstrapResult(recovery, None))
|
||||
return
|
||||
session = self._store.start_or_resume_polling(profile_id)
|
||||
self.bootstrap_finished.emit(_BootstrapResult(recovery, session))
|
||||
except Exception as error:
|
||||
self.failed.emit("bootstrap", error)
|
||||
|
||||
@Slot(str)
|
||||
def inspect_restart(self, profile_id: str) -> None:
|
||||
try:
|
||||
snapshot = self._store.recovery_snapshot(profile_id)
|
||||
recovery = RecoveryStatus.from_snapshot(snapshot)
|
||||
stopped = None
|
||||
if snapshot.session is not None and snapshot.session.accept_new:
|
||||
stopped = self._store.request_stop(profile_id)
|
||||
self.bootstrap_finished.emit(_BootstrapResult(recovery, None, stopped))
|
||||
except Exception as error:
|
||||
self.failed.emit("inspect", error)
|
||||
|
||||
@Slot(str)
|
||||
def claim(self, profile_id: str) -> None:
|
||||
try:
|
||||
# DurableClientGateway 在返回前已经提交 EMPTY 或 active claim;UI 不能
|
||||
# 以 generation 过期为由丢弃这个业务结果。
|
||||
if self._gateway is None:
|
||||
raise RuntimeError("claim_gateway_not_configured")
|
||||
self.claim_finished.emit(self._gateway.claim_next(profile_id))
|
||||
except Exception as error:
|
||||
self.failed.emit("claim", error)
|
||||
|
||||
@Slot(str)
|
||||
def stop(self, profile_id: str) -> None:
|
||||
try:
|
||||
self.stop_finished.emit(self._store.request_stop(profile_id))
|
||||
except Exception as error:
|
||||
self.failed.emit("stop", error)
|
||||
|
||||
|
||||
class PollingCoordinator(QObject):
|
||||
"""把计时、阻塞 I/O 和可见状态收敛到一个会话边界。"""
|
||||
|
||||
state_changed = Signal(object, str, int)
|
||||
claim_visible = Signal(object)
|
||||
recovery_status_changed = Signal(object)
|
||||
configuration_freeze_changed = Signal(bool)
|
||||
settled = Signal()
|
||||
_configure_gateway_requested = Signal(object)
|
||||
_bootstrap_requested = Signal(str)
|
||||
_inspect_requested = Signal(str)
|
||||
_claim_requested = Signal(str)
|
||||
_stop_requested_signal = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
profile_id: str,
|
||||
store: PollingStore | None,
|
||||
gateway_factory: Callable[[ProfileSettings], ClaimGateway] | None,
|
||||
consumer: ExecutionConsumer | None,
|
||||
profile_settings: ProfileSettings | None = None,
|
||||
readiness: StartReadiness | None = None,
|
||||
poll_interval_seconds: int = 15,
|
||||
failure_threshold: int = 3,
|
||||
timer_interval_ms: int | None = None,
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
if not 5 <= poll_interval_seconds <= 300:
|
||||
raise ValueError("invalid_poll_interval")
|
||||
if not 1 <= failure_threshold <= 10:
|
||||
raise ValueError("invalid_failure_threshold")
|
||||
self.profile_id = profile_id
|
||||
self._store = store
|
||||
self._gateway_factory = gateway_factory
|
||||
self._gateway: ClaimGateway | None = None
|
||||
self._consumer = consumer
|
||||
self._profile_settings = profile_settings
|
||||
self._frozen_profile: ProfileSettings | None = None
|
||||
self._readiness = readiness
|
||||
self.recovery_status: RecoveryStatus | None = None
|
||||
self._timer_interval_override = timer_interval_ms
|
||||
self._interval_ms = timer_interval_ms or (
|
||||
profile_settings.poll_interval_seconds * 1000 if profile_settings is not None else poll_interval_seconds * 1000
|
||||
)
|
||||
if self._interval_ms <= 0:
|
||||
raise ValueError("invalid_timer_interval")
|
||||
self._failure_threshold = (
|
||||
profile_settings.failure_threshold if profile_settings is not None else failure_threshold
|
||||
)
|
||||
self._consecutive_failures = 0
|
||||
self._operation: str | None = None
|
||||
self._stop_requested = False
|
||||
self._epoch = 0
|
||||
self._scheduled_epoch: int | None = None
|
||||
self._post_stop_state = PollingState.STOPPED
|
||||
self._post_stop_reason = "轮询已停止。"
|
||||
self._thread: QThread | None = None
|
||||
self._worker: _PollingWorker | None = None
|
||||
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.timeout.connect(self._on_timer_timeout)
|
||||
|
||||
if consumer is None:
|
||||
self.state = PollingState.BLOCKED
|
||||
self.reason = "单趟执行能力尚未接入,不能领取真实任务。"
|
||||
elif store is None or gateway_factory is None:
|
||||
self.state = PollingState.BLOCKED
|
||||
self.reason = "轮询依赖未完整注入,不能领取真实任务。"
|
||||
elif profile_settings is None:
|
||||
self.state = PollingState.BLOCKED
|
||||
self.reason = "尚未保存完整配置,不能开始轮询。"
|
||||
elif readiness is None or not readiness.ready:
|
||||
self.state = PollingState.BLOCKED
|
||||
self.reason = "执行就绪条件未满足,不能开始轮询。" if readiness is None else readiness.reason
|
||||
else:
|
||||
self.state = PollingState.STOPPED
|
||||
self.reason = "轮询已停止。"
|
||||
|
||||
if store is not None:
|
||||
self._thread = QThread(self)
|
||||
self._worker = _PollingWorker(store)
|
||||
self._worker.moveToThread(self._thread)
|
||||
self._configure_gateway_requested.connect(self._worker.configure_gateway)
|
||||
self._inspect_requested.connect(self._worker.inspect_restart)
|
||||
self._bootstrap_requested.connect(self._worker.bootstrap)
|
||||
self._claim_requested.connect(self._worker.claim)
|
||||
self._stop_requested_signal.connect(self._worker.stop)
|
||||
self._worker.bootstrap_finished.connect(self._on_bootstrap_finished)
|
||||
self._worker.claim_finished.connect(self._on_claim_finished)
|
||||
self._worker.stop_finished.connect(self._on_stop_finished)
|
||||
self._worker.failed.connect(self._on_worker_failed)
|
||||
self._thread.start()
|
||||
self._operation = "inspect"
|
||||
self._set_state(PollingState.STARTING, "正在读取重启恢复状态并关闭遗留自动领取许可…")
|
||||
self._inspect_requested.emit(self.profile_id)
|
||||
|
||||
@property
|
||||
def can_start(self) -> bool:
|
||||
return (
|
||||
self._consumer is not None
|
||||
and self._store is not None
|
||||
and self._gateway_factory is not None
|
||||
and self._profile_settings is not None
|
||||
and self._readiness is not None
|
||||
and self._readiness.ready
|
||||
and self.state == PollingState.STOPPED
|
||||
and self._operation is None
|
||||
)
|
||||
|
||||
@property
|
||||
def consecutive_failures(self) -> int:
|
||||
return self._consecutive_failures
|
||||
|
||||
@property
|
||||
def operation_in_flight(self) -> bool:
|
||||
return self._operation is not None
|
||||
|
||||
def update_profile_settings(self, settings: ProfileSettings) -> None:
|
||||
if self._operation is None and self.state in (PollingState.STOPPED, PollingState.BLOCKED):
|
||||
self._profile_settings = settings
|
||||
self._refresh_idle_gate()
|
||||
|
||||
def update_readiness(self, readiness: StartReadiness) -> None:
|
||||
self._readiness = readiness
|
||||
self._refresh_idle_gate()
|
||||
|
||||
def _refresh_idle_gate(self) -> None:
|
||||
if self._operation is not None or self.state == PollingState.RECOVERY_REQUIRED:
|
||||
return
|
||||
if self._consumer is None:
|
||||
self._set_state(PollingState.BLOCKED, "单趟执行能力尚未接入,不能领取真实任务。")
|
||||
elif self._gateway_factory is None or self._store is None:
|
||||
self._set_state(PollingState.BLOCKED, "轮询依赖未完整注入,不能领取真实任务。")
|
||||
elif self._profile_settings is None:
|
||||
self._set_state(PollingState.BLOCKED, "尚未保存完整配置,不能开始轮询。")
|
||||
elif self._readiness is None or not self._readiness.ready:
|
||||
reason = "执行就绪条件未满足,不能开始轮询。" if self._readiness is None else self._readiness.reason
|
||||
self._set_state(PollingState.BLOCKED, reason)
|
||||
else:
|
||||
self._set_state(PollingState.STOPPED, "轮询已停止。")
|
||||
|
||||
def start(self) -> None:
|
||||
# 这道门禁必须早于任何 store/gateway 调用;独立应用没有 consumer,
|
||||
# 即使调用方绕过禁用按钮直接调用本方法也保持零 HTTP。
|
||||
if self._consumer is None:
|
||||
self._set_state(PollingState.BLOCKED, "单趟执行能力尚未接入,不能领取真实任务。")
|
||||
return
|
||||
if self._store is None or self._gateway_factory is None or self._worker is None:
|
||||
self._set_state(PollingState.BLOCKED, "轮询依赖未完整注入,不能领取真实任务。")
|
||||
return
|
||||
if self._profile_settings is None:
|
||||
self._set_state(PollingState.BLOCKED, "尚未保存完整配置,不能开始轮询。")
|
||||
return
|
||||
if self._readiness is None or not self._readiness.ready:
|
||||
reason = "执行就绪条件未满足,不能开始轮询。" if self._readiness is None else self._readiness.reason
|
||||
self._set_state(PollingState.BLOCKED, reason)
|
||||
return
|
||||
if not self.can_start:
|
||||
return
|
||||
frozen = self._profile_settings
|
||||
try:
|
||||
gateway = self._gateway_factory(frozen)
|
||||
except Exception:
|
||||
self._set_state(PollingState.BLOCKED, "领取网关无法按本次冻结配置建立,不能开始轮询。")
|
||||
return
|
||||
if gateway is None or not hasattr(gateway, "claim_next"):
|
||||
self._set_state(PollingState.BLOCKED, "领取网关未完整注入,不能开始轮询。")
|
||||
return
|
||||
self._frozen_profile = frozen
|
||||
self._interval_ms = self._timer_interval_override or frozen.poll_interval_seconds * 1000
|
||||
self._failure_threshold = frozen.failure_threshold
|
||||
self._gateway = gateway
|
||||
self._configure_gateway_requested.emit(gateway)
|
||||
self._epoch += 1
|
||||
self._scheduled_epoch = None
|
||||
self._timer.stop()
|
||||
self._stop_requested = False
|
||||
self._post_stop_state = PollingState.STOPPED
|
||||
self._consecutive_failures = 0
|
||||
self._operation = "bootstrap"
|
||||
self._set_state(PollingState.STARTING, "正在读取本地恢复状态并建立轮询会话…")
|
||||
self._bootstrap_requested.emit(self.profile_id)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self.state == PollingState.STOPPED and self._operation is None:
|
||||
return
|
||||
if self._store is None or self._worker is None:
|
||||
return
|
||||
# epoch/latch 双保险:stopEvent 先让 timer 队列中已经排队的 timeout
|
||||
# 失效,再处理持久 stop;之后只有显式 Start 才会获得新 epoch。
|
||||
self._epoch += 1
|
||||
self._scheduled_epoch = None
|
||||
self._stop_requested = True
|
||||
self._timer.stop()
|
||||
if self._operation in ("inspect", "bootstrap", "claim", "stop"):
|
||||
if self._operation == "claim":
|
||||
self._set_state(PollingState.CLAIMING, "正在等待本次有界领取返回;不会取消或重发请求。")
|
||||
return
|
||||
self._request_stop(PollingState.STOPPED, "轮询已停止;只阻止下一次领取。")
|
||||
|
||||
def shutdown(self, wait_ms: int = 5000) -> bool:
|
||||
"""只结束空闲 worker;飞行中 I/O 必须由事件循环等待 settled。"""
|
||||
|
||||
self._timer.stop()
|
||||
if self._operation is not None:
|
||||
return False
|
||||
if self._thread is not None and self._thread.isRunning():
|
||||
self._thread.quit()
|
||||
return self._thread.wait(wait_ms)
|
||||
return True
|
||||
|
||||
@Slot(object)
|
||||
def _on_bootstrap_finished(self, raw: object) -> None:
|
||||
completed_operation = self._operation
|
||||
self._operation = None
|
||||
result = raw
|
||||
if not isinstance(result, _BootstrapResult):
|
||||
self._block("本地恢复结果无效,轮询已阻止。")
|
||||
return
|
||||
self.recovery_status = result.recovery
|
||||
self.recovery_status_changed.emit(result.recovery)
|
||||
self.configuration_freeze_changed.emit(
|
||||
result.recovery.has_pending_claim or result.recovery.has_active_claim
|
||||
)
|
||||
if completed_operation == "inspect":
|
||||
if result.recovery.has_active_claim:
|
||||
self._set_state(PollingState.RECOVERY_REQUIRED, "遗留会话已停止;必须先安全恢复当前任务。")
|
||||
elif result.recovery.has_pending_claim:
|
||||
self._set_state(PollingState.STOPPED, "遗留领取请求已停止;显式开始后只使用原幂等键恢复。")
|
||||
else:
|
||||
self._refresh_idle_gate()
|
||||
self.settled.emit()
|
||||
return
|
||||
if result.normalized_stop is not None:
|
||||
if result.recovery.has_active_claim:
|
||||
self._set_state(PollingState.RECOVERY_REQUIRED, "遗留会话已停止;必须先安全恢复当前任务。")
|
||||
else:
|
||||
self._set_state(PollingState.STOPPED, "遗留会话已停止;请再次显式开始轮询。")
|
||||
self.settled.emit()
|
||||
return
|
||||
if result.recovery.has_active_claim:
|
||||
self._set_state(
|
||||
PollingState.RECOVERY_REQUIRED,
|
||||
"检测到未关闭的采购任务,必须先完成安全恢复,不能领取新任务。",
|
||||
)
|
||||
self.settled.emit()
|
||||
return
|
||||
if self._stop_requested:
|
||||
self._request_stop(PollingState.STOPPED, "轮询已停止;未发起领取请求。")
|
||||
return
|
||||
if result.recovery.has_pending_claim:
|
||||
self._set_state(PollingState.RECOVERING, "正在使用原幂等键恢复结果不明的领取请求…")
|
||||
else:
|
||||
self._set_state(PollingState.WAITING, "轮询会话已启动,正在等待领取。")
|
||||
self._schedule_claim(0)
|
||||
|
||||
def _schedule_claim(self, delay_ms: int) -> None:
|
||||
self._scheduled_epoch = self._epoch
|
||||
self._timer.start(delay_ms)
|
||||
|
||||
@Slot()
|
||||
def _on_timer_timeout(self) -> None:
|
||||
scheduled_epoch = self._scheduled_epoch
|
||||
self._scheduled_epoch = None
|
||||
if scheduled_epoch != self._epoch:
|
||||
return
|
||||
self._begin_claim(scheduled_epoch)
|
||||
|
||||
def _begin_claim(self, dispatch_epoch: int) -> None:
|
||||
if dispatch_epoch != self._epoch:
|
||||
return
|
||||
if self._operation is not None or self._stop_requested:
|
||||
return
|
||||
if self.state not in (PollingState.WAITING, PollingState.RECOVERING):
|
||||
return
|
||||
self._operation = "claim"
|
||||
self.configuration_freeze_changed.emit(True)
|
||||
self._set_state(PollingState.CLAIMING, "正在领取已授权任务…")
|
||||
self._claim_requested.emit(self.profile_id)
|
||||
|
||||
@Slot(object)
|
||||
def _on_claim_finished(self, claimed: object) -> None:
|
||||
self._operation = None
|
||||
self._consecutive_failures = 0
|
||||
if claimed is not None and not isinstance(claimed, ClaimedTask):
|
||||
self._request_stop(PollingState.BLOCKED, "领取结果类型无效,轮询已阻止。")
|
||||
return
|
||||
if isinstance(claimed, ClaimedTask):
|
||||
self.claim_visible.emit(ClaimedTaskView.from_claim(claimed))
|
||||
if self._stop_requested:
|
||||
self._request_stop(
|
||||
PollingState.RECOVERY_REQUIRED,
|
||||
"停止期间领取已落库;必须先安全恢复该任务,不能领取下一条。",
|
||||
)
|
||||
return
|
||||
try:
|
||||
consumer = self._consumer
|
||||
frozen_profile = self._frozen_profile
|
||||
if consumer is None or frozen_profile is None:
|
||||
raise RuntimeError("execution_consumer_missing")
|
||||
# consumer 只能使用本次显式 Start 冻结的不可变配置;不得在
|
||||
# 已领取后回读可变 UI/store,否则 ADB 身份和超时会发生趟内漂移。
|
||||
consumer.accept_claim(claimed, frozen_profile)
|
||||
except Exception:
|
||||
self._request_stop(
|
||||
PollingState.RECOVERY_REQUIRED,
|
||||
"执行 consumer 未接收已落库任务;必须安全恢复,不能重新领取。",
|
||||
)
|
||||
return
|
||||
self._set_state(PollingState.ACTIVE, "任务已安全领取并交给单趟执行能力。")
|
||||
return
|
||||
self.configuration_freeze_changed.emit(False)
|
||||
if self._stop_requested:
|
||||
self._request_stop(PollingState.STOPPED, "轮询已停止;本次没有可领取任务。")
|
||||
return
|
||||
self._set_state(PollingState.WAITING, "暂无已授权任务,等待下一次轮询。")
|
||||
self._schedule_claim(self._interval_ms)
|
||||
|
||||
def _request_stop(self, target: PollingState, reason: str) -> None:
|
||||
if self._operation == "stop":
|
||||
return
|
||||
self._timer.stop()
|
||||
self._post_stop_state = target
|
||||
self._post_stop_reason = reason
|
||||
self._operation = "stop"
|
||||
self._stop_requested_signal.emit(self.profile_id)
|
||||
|
||||
@Slot(object)
|
||||
def _on_stop_finished(self, session: object) -> None:
|
||||
self._operation = None
|
||||
if not isinstance(session, PollingSession) or session.accept_new:
|
||||
self._block("停止状态未能持久化,轮询已阻止。")
|
||||
return
|
||||
self._set_state(self._post_stop_state, self._post_stop_reason)
|
||||
self.settled.emit()
|
||||
|
||||
@Slot(str, object)
|
||||
def _on_worker_failed(self, operation: str, error: object) -> None:
|
||||
self._operation = None
|
||||
if operation == "inspect":
|
||||
if isinstance(error, StateError) and error.reason == "profile_not_found":
|
||||
self.recovery_status = RecoveryStatus(False, False, False, False, False)
|
||||
self.recovery_status_changed.emit(self.recovery_status)
|
||||
self._refresh_idle_gate()
|
||||
else:
|
||||
self._block("本地恢复状态无法安全读取;已停止且不能领取任务。")
|
||||
self.settled.emit()
|
||||
return
|
||||
if (
|
||||
operation == "claim"
|
||||
and isinstance(error, AmbiguousRemoteError)
|
||||
and error.reason in SAFE_AMBIGUOUS_REASONS
|
||||
):
|
||||
self._consecutive_failures += 1
|
||||
if self._stop_requested:
|
||||
self._request_stop(PollingState.STOPPED, "轮询已停止;结果不明的原领取请求已保留。")
|
||||
elif self._consecutive_failures >= self._failure_threshold:
|
||||
self._request_stop(
|
||||
PollingState.BLOCKED,
|
||||
"连续领取失败达到阈值;原幂等请求已保留,需排查后重新开始。",
|
||||
)
|
||||
else:
|
||||
self._set_state(
|
||||
PollingState.RECOVERING,
|
||||
"领取结果不明;等待使用相同幂等键恢复,不会创建新请求。",
|
||||
)
|
||||
self._schedule_claim(self._interval_ms)
|
||||
return
|
||||
|
||||
if operation == "claim" and isinstance(error, AmbiguousRemoteError):
|
||||
self.configuration_freeze_changed.emit(True)
|
||||
self._request_stop(
|
||||
PollingState.BLOCKED,
|
||||
"领取响应无法证明可安全定时恢复;原槽已保留,需显式开始后同键恢复。",
|
||||
)
|
||||
return
|
||||
|
||||
if operation == "stop":
|
||||
self._block("停止状态无法安全落库,轮询已阻止;未清除任何恢复事实。")
|
||||
return
|
||||
|
||||
if isinstance(error, CredentialRemoteError):
|
||||
reason = "设备凭据无效或已撤销;修复凭据后再手工开始。"
|
||||
elif isinstance(error, ProtocolRemoteError):
|
||||
reason = "服务响应与固定协议不兼容;已停止普通重试。"
|
||||
elif isinstance(error, ManualRemoteError):
|
||||
reason = "服务端要求人工处理;已停止普通重试。"
|
||||
elif isinstance(error, ClientError):
|
||||
reason = "本地安全状态无法推进;已停止普通重试。"
|
||||
else:
|
||||
reason = "轮询发生未分类错误;已失败闭合。"
|
||||
|
||||
if operation == "claim":
|
||||
# DurableClientGateway 已把协议错误和 409 人工冲突标成 terminal,
|
||||
# 二者没有 pending/active;凭据或本地错误则可能保留 pending,继续冻结。
|
||||
self.configuration_freeze_changed.emit(
|
||||
not isinstance(error, (ProtocolRemoteError, ManualRemoteError))
|
||||
)
|
||||
|
||||
if operation in ("bootstrap", "configure"):
|
||||
self._block(reason)
|
||||
else:
|
||||
self._request_stop(PollingState.BLOCKED, reason)
|
||||
|
||||
def _block(self, reason: str) -> None:
|
||||
self._timer.stop()
|
||||
self._scheduled_epoch = None
|
||||
self._set_state(PollingState.BLOCKED, reason)
|
||||
if self._operation is None:
|
||||
self.settled.emit()
|
||||
|
||||
def _set_state(self, state: PollingState, reason: str) -> None:
|
||||
self.state = state
|
||||
self.reason = reason
|
||||
self.state_changed.emit(state, reason, self._consecutive_failures)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""采购工具原生 Qt Widgets 界面。"""
|
||||
|
||||
from .main_window import PurchaseToolWindow
|
||||
|
||||
__all__ = ["PurchaseToolWindow"]
|
||||
@@ -0,0 +1,419 @@
|
||||
"""采购执行 Tab:状态、当前任务、滚动日志和历史记录主从视图。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QModelIndex, QSize, Qt, Signal, Slot
|
||||
from PySide6.QtGui import QAction, QKeySequence, QShortcut
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QFrame,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMenu,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSplitter,
|
||||
QStackedWidget,
|
||||
QTableView,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from cmbuyer_client.polling.coordinator import ClaimedTaskView, PollingCoordinator, PollingState
|
||||
from cmbuyer_client.logging_policy import redact_text
|
||||
|
||||
from .records import PurchaseRecord, PurchaseRecordModel, PurchaseRecordProvider
|
||||
|
||||
|
||||
class _LogView(QPlainTextEdit):
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("rollingLog")
|
||||
self.setReadOnly(True)
|
||||
self.setPlaceholderText("轮询启动后将在这里显示脱敏日志。")
|
||||
self.setAccessibleName("滚动日志")
|
||||
|
||||
def append_event(self, text: str) -> None:
|
||||
bar = self.verticalScrollBar()
|
||||
follow = bar.value() >= bar.maximum() - 2
|
||||
self.appendPlainText(redact_text(text))
|
||||
if follow:
|
||||
bar.setValue(bar.maximum())
|
||||
|
||||
|
||||
class _RecordTableView(QTableView):
|
||||
"""把双击与 Enter 收敛为唯一 activation 信号,避免平台重复发命令。"""
|
||||
|
||||
record_activated = Signal(object)
|
||||
|
||||
def mouseDoubleClickEvent(self, event) -> None:
|
||||
index = self.indexAt(event.position().toPoint())
|
||||
if index.isValid():
|
||||
self.setCurrentIndex(index.siblingAtColumn(0))
|
||||
self.record_activated.emit(index)
|
||||
event.accept()
|
||||
return
|
||||
super().mouseDoubleClickEvent(event)
|
||||
|
||||
def keyPressEvent(self, event) -> None:
|
||||
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter) and self.currentIndex().isValid():
|
||||
self.record_activated.emit(self.currentIndex())
|
||||
event.accept()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
|
||||
class ExecutionPage(QWidget):
|
||||
LIVE_PAGE = 0
|
||||
DETAIL_PAGE = 1
|
||||
COMPACT_DETAIL_WIDTH = 760
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PollingCoordinator,
|
||||
record_provider: PurchaseRecordProvider | None = None,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("executionPage")
|
||||
self._coordinator = coordinator
|
||||
self._selected_record_id: str | None = None
|
||||
self._saved_scroll = 0
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(12, 12, 12, 12)
|
||||
|
||||
status_row = QHBoxLayout()
|
||||
self.service_status = self._status("采购服务", "待首次真实领取验证")
|
||||
self.device_status = self._status("ADB", "待后续执行能力验证")
|
||||
self.app_status = self._status("拼多多版本", "待后续执行能力验证")
|
||||
self.session_status = self._status("会话", "已停止")
|
||||
for widget in (self.service_status, self.device_status, self.app_status, self.session_status):
|
||||
status_row.addWidget(widget)
|
||||
status_row.addStretch(1)
|
||||
|
||||
self.poll_action = QAction("开始轮询", self)
|
||||
self.poll_action.setObjectName("pollAction")
|
||||
self.poll_action.triggered.connect(self._toggle_polling)
|
||||
self.addAction(self.poll_action)
|
||||
self.poll_button = QPushButton()
|
||||
self.poll_button.setObjectName("pollButton")
|
||||
self.poll_button.clicked.connect(self.poll_action.trigger)
|
||||
status_row.addWidget(self.poll_button)
|
||||
outer.addLayout(status_row)
|
||||
|
||||
self.banner = QLabel()
|
||||
self.banner.setObjectName("sessionBanner")
|
||||
self.banner.setWordWrap(True)
|
||||
self.banner.setAccessibleName("轮询会话状态")
|
||||
self.banner.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
outer.addWidget(self.banner)
|
||||
|
||||
self.body_splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
self.body_splitter.setObjectName("executionSplitter")
|
||||
self.body_splitter.setChildrenCollapsible(False)
|
||||
self.left_stack = QStackedWidget()
|
||||
self.left_stack.setObjectName("leftWorkspace")
|
||||
self.left_stack.addWidget(self._build_live_page())
|
||||
self.left_stack.addWidget(self._build_detail_page())
|
||||
self.body_splitter.addWidget(self.left_stack)
|
||||
self.body_splitter.addWidget(self._build_records_page())
|
||||
self.body_splitter.setStretchFactor(0, 2)
|
||||
self.body_splitter.setStretchFactor(1, 1)
|
||||
self.body_splitter.setSizes([760, 380])
|
||||
outer.addWidget(self.body_splitter, 1)
|
||||
|
||||
self.view_record_action = QAction("查看所选记录", self)
|
||||
self.view_record_action.setObjectName("viewSelectedRecord")
|
||||
self.view_record_action.setEnabled(False)
|
||||
self.view_record_action.triggered.connect(self.open_selected_record)
|
||||
self.addAction(self.view_record_action)
|
||||
self.view_record_button.setDefaultAction(self.view_record_action)
|
||||
|
||||
self.return_action = QAction("返回当前任务", self)
|
||||
self.return_action.setObjectName("returnToCurrentTask")
|
||||
self.return_action.setEnabled(False)
|
||||
self.return_action.triggered.connect(self.return_to_live)
|
||||
self.addAction(self.return_action)
|
||||
self.return_button.setDefaultAction(self.return_action)
|
||||
self.escape_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Escape), self)
|
||||
self.escape_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
|
||||
self.escape_shortcut.activated.connect(self._escape)
|
||||
|
||||
self.record_view.clicked.connect(self._on_record_selected)
|
||||
self.record_view.record_activated.connect(self._open_index)
|
||||
self.record_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.record_view.customContextMenuRequested.connect(self._show_record_menu)
|
||||
self.record_view.selectionModel().currentChanged.connect(self._on_current_changed)
|
||||
coordinator.state_changed.connect(self._on_polling_state)
|
||||
coordinator.claim_visible.connect(self._show_claimed_task)
|
||||
self._on_polling_state(coordinator.state, coordinator.reason, coordinator.consecutive_failures)
|
||||
if record_provider is not None:
|
||||
# T-304 没有公共历史仓库;只接受调用方准备好的 View DTO 快照,
|
||||
# 独立应用不注入 provider,模型保持真实空态。
|
||||
self.set_records(record_provider.snapshot())
|
||||
|
||||
@staticmethod
|
||||
def _status(name: str, value: str) -> QLabel:
|
||||
label = QLabel(f"{name}\n{value}")
|
||||
label.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
label.setMinimumWidth(118)
|
||||
label.setAccessibleName(name)
|
||||
return label
|
||||
|
||||
def _build_live_page(self) -> QWidget:
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
task_group = QGroupBox("当前任务")
|
||||
task_layout = QHBoxLayout(task_group)
|
||||
self.current_task_text = QLabel("当前没有任务。\n启动后只领取已授权任务。")
|
||||
self.current_task_text.setObjectName("currentTaskText")
|
||||
self.current_task_text.setWordWrap(True)
|
||||
self.current_task_text.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
|
||||
self.current_task_text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
self.current_image = QLabel("暂无可信商品图片")
|
||||
self.current_image.setObjectName("currentTaskImage")
|
||||
self.current_image.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.current_image.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
self.current_image.setMinimumSize(QSize(180, 120))
|
||||
self.current_image.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
task_layout.addWidget(self.current_task_text, 2)
|
||||
task_layout.addWidget(self.current_image, 1)
|
||||
layout.addWidget(task_group, 1)
|
||||
|
||||
log_group = QGroupBox("滚动日志")
|
||||
log_layout = QVBoxLayout(log_group)
|
||||
self.log_view = _LogView()
|
||||
log_layout.addWidget(self.log_view)
|
||||
layout.addWidget(log_group, 2)
|
||||
return page
|
||||
|
||||
def _build_detail_page(self) -> QWidget:
|
||||
page = QWidget()
|
||||
page.setObjectName("recordDetailPage")
|
||||
layout = QVBoxLayout(page)
|
||||
header = QHBoxLayout()
|
||||
self.detail_title = QLabel("采购记录详情")
|
||||
self.detail_title.setObjectName("recordDetailTitle")
|
||||
self.detail_title.setStyleSheet("font-size: 18px; font-weight: 600;")
|
||||
header.addWidget(self.detail_title)
|
||||
header.addStretch(1)
|
||||
self.return_button = QToolButton()
|
||||
self.return_button.setObjectName("returnCurrentTaskButton")
|
||||
header.addWidget(self.return_button)
|
||||
layout.addLayout(header)
|
||||
|
||||
top = QSplitter(Qt.Orientation.Horizontal)
|
||||
self.detail_original = QPlainTextEdit()
|
||||
self.detail_original.setObjectName("recordOriginalText")
|
||||
self.detail_original.setReadOnly(True)
|
||||
self.detail_original.setPlaceholderText("没有可显示的原始文字。")
|
||||
self.detail_image = QLabel("没有可显示的可信图片")
|
||||
self.detail_image.setObjectName("recordImage")
|
||||
self.detail_image.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.detail_image.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
top.addWidget(self.detail_original)
|
||||
top.addWidget(self.detail_image)
|
||||
top.setStretchFactor(0, 2)
|
||||
top.setStretchFactor(1, 1)
|
||||
layout.addWidget(top, 2)
|
||||
|
||||
result_group = QGroupBox("采购结果")
|
||||
result_layout = QVBoxLayout(result_group)
|
||||
self.detail_result = QPlainTextEdit()
|
||||
self.detail_result.setObjectName("recordResult")
|
||||
self.detail_result.setReadOnly(True)
|
||||
self.detail_result.setPlaceholderText("暂无采购结果。")
|
||||
result_layout.addWidget(self.detail_result)
|
||||
layout.addWidget(result_group, 1)
|
||||
return page
|
||||
|
||||
def _build_records_page(self) -> QWidget:
|
||||
page = QGroupBox("采购记录")
|
||||
page.setObjectName("recordsPanel")
|
||||
self.records_panel = page
|
||||
layout = QVBoxLayout(page)
|
||||
header = QHBoxLayout()
|
||||
self.records_summary = QLabel("暂无记录")
|
||||
header.addWidget(self.records_summary)
|
||||
header.addStretch(1)
|
||||
self.view_record_button = QToolButton()
|
||||
self.view_record_button.setObjectName("viewSelectedRecordButton")
|
||||
header.addWidget(self.view_record_button)
|
||||
layout.addLayout(header)
|
||||
self.record_model = PurchaseRecordModel(parent=self)
|
||||
self.record_view = _RecordTableView()
|
||||
self.record_view.setObjectName("purchaseRecordTable")
|
||||
self.record_view.setModel(self.record_model)
|
||||
self.record_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.record_view.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.record_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.record_view.setAlternatingRowColors(True)
|
||||
self.record_view.setSortingEnabled(False)
|
||||
self.record_view.horizontalHeader().setStretchLastSection(False)
|
||||
self.record_view.horizontalHeader().setSectionResizeMode(0, self.record_view.horizontalHeader().ResizeMode.Stretch)
|
||||
self.record_view.horizontalHeader().setSectionResizeMode(1, self.record_view.horizontalHeader().ResizeMode.ResizeToContents)
|
||||
self.record_view.verticalHeader().setVisible(False)
|
||||
layout.addWidget(self.record_view)
|
||||
return page
|
||||
|
||||
def set_records(self, records: list[PurchaseRecord]) -> None:
|
||||
selected_id = self._selected_record_id
|
||||
self.record_model.set_records(records)
|
||||
self.records_summary.setText(f"共 {len(records)} 条" if records else "暂无记录")
|
||||
if selected_id is not None:
|
||||
row = self.record_model.row_for_id(selected_id)
|
||||
if row >= 0:
|
||||
self.record_view.setCurrentIndex(self.record_model.index(row, 0))
|
||||
if self.left_stack.currentIndex() == self.DETAIL_PAGE:
|
||||
self._render_record(self.record_model.record_at(row))
|
||||
return
|
||||
self._selected_record_id = None
|
||||
self.view_record_action.setEnabled(False)
|
||||
if self.left_stack.currentIndex() == self.DETAIL_PAGE:
|
||||
self.return_to_live()
|
||||
|
||||
@Slot(object, str, int)
|
||||
def _on_polling_state(self, state: object, reason: str, failures: int) -> None:
|
||||
polling_state = state if isinstance(state, PollingState) else PollingState.BLOCKED
|
||||
self.session_status.setText(f"会话\n{self._state_text(polling_state)}")
|
||||
suffix = f"(连续失败 {failures} 次)" if failures else ""
|
||||
self.banner.setText(reason + suffix)
|
||||
running = polling_state in (
|
||||
PollingState.STARTING,
|
||||
PollingState.RECOVERING,
|
||||
PollingState.WAITING,
|
||||
PollingState.CLAIMING,
|
||||
PollingState.ACTIVE,
|
||||
)
|
||||
self.poll_action.setText("停止轮询" if running else "开始轮询")
|
||||
self.poll_action.setEnabled(running or self._coordinator.can_start)
|
||||
self.poll_button.setText(self.poll_action.text())
|
||||
self.poll_button.setEnabled(self.poll_action.isEnabled())
|
||||
self.poll_button.setToolTip("" if self.poll_action.isEnabled() else reason)
|
||||
|
||||
@staticmethod
|
||||
def _state_text(state: PollingState) -> str:
|
||||
return {
|
||||
PollingState.STOPPED: "已停止",
|
||||
PollingState.STARTING: "启动中",
|
||||
PollingState.BLOCKED: "已阻止",
|
||||
PollingState.RECOVERING: "安全恢复",
|
||||
PollingState.WAITING: "等待领取",
|
||||
PollingState.CLAIMING: "正在领取",
|
||||
PollingState.ACTIVE: "任务执行中",
|
||||
PollingState.RECOVERY_REQUIRED: "待安全恢复",
|
||||
}[state]
|
||||
|
||||
@Slot()
|
||||
def _toggle_polling(self) -> None:
|
||||
if self._coordinator.state in (
|
||||
PollingState.STARTING,
|
||||
PollingState.RECOVERING,
|
||||
PollingState.WAITING,
|
||||
PollingState.CLAIMING,
|
||||
PollingState.ACTIVE,
|
||||
):
|
||||
self._coordinator.stop()
|
||||
else:
|
||||
self._coordinator.start()
|
||||
|
||||
@Slot(QModelIndex)
|
||||
def _on_record_selected(self, index: QModelIndex) -> None:
|
||||
record = self.record_model.record_at(index.row())
|
||||
if record is None:
|
||||
return
|
||||
self._selected_record_id = record.record_id
|
||||
self.view_record_action.setEnabled(True)
|
||||
if self.left_stack.currentIndex() == self.DETAIL_PAGE:
|
||||
self._saved_scroll = self.record_view.verticalScrollBar().value()
|
||||
self._render_record(record)
|
||||
|
||||
@Slot(object)
|
||||
def _show_claimed_task(self, raw: object) -> None:
|
||||
if not isinstance(raw, ClaimedTaskView):
|
||||
return
|
||||
self.current_task_text.setText(
|
||||
f"标题:{raw.title}\n任务 ID:{raw.task_id}\n状态:{raw.status}"
|
||||
)
|
||||
self.log_view.append_event(f"已安全领取任务 {raw.task_id};等待单趟执行能力处理。")
|
||||
|
||||
@Slot(QModelIndex, QModelIndex)
|
||||
def _on_current_changed(self, current: QModelIndex, previous: QModelIndex) -> None:
|
||||
del previous
|
||||
if current.isValid():
|
||||
self._on_record_selected(current)
|
||||
|
||||
@Slot(QModelIndex)
|
||||
def _open_index(self, index: QModelIndex) -> None:
|
||||
if index.isValid():
|
||||
self.record_view.setCurrentIndex(index.siblingAtColumn(0))
|
||||
self._on_record_selected(index)
|
||||
self.view_record_action.trigger()
|
||||
|
||||
@Slot()
|
||||
def open_selected_record(self) -> None:
|
||||
if self._selected_record_id is None:
|
||||
return
|
||||
row = self.record_model.row_for_id(self._selected_record_id)
|
||||
record = self.record_model.record_at(row)
|
||||
if record is None:
|
||||
return
|
||||
self._saved_scroll = self.record_view.verticalScrollBar().value()
|
||||
self._render_record(record)
|
||||
self.left_stack.setCurrentIndex(self.DETAIL_PAGE)
|
||||
self.return_action.setEnabled(True)
|
||||
self._apply_compact_detail()
|
||||
self.return_button.setFocus()
|
||||
|
||||
def _render_record(self, record: PurchaseRecord | None) -> None:
|
||||
if record is None:
|
||||
self.detail_title.setText("记录不存在")
|
||||
self.detail_original.clear()
|
||||
self.detail_result.clear()
|
||||
self.detail_image.setText("没有可显示的可信图片")
|
||||
return
|
||||
self.detail_title.setText(record.title)
|
||||
self.detail_original.setPlainText(record.original_text)
|
||||
self.detail_result.setPlainText(record.result_text)
|
||||
self.detail_image.setText(record.image_description or "没有可显示的可信图片")
|
||||
|
||||
@Slot()
|
||||
def return_to_live(self) -> None:
|
||||
if self.left_stack.currentIndex() != self.DETAIL_PAGE:
|
||||
return
|
||||
self.left_stack.setCurrentIndex(self.LIVE_PAGE)
|
||||
self.return_action.setEnabled(False)
|
||||
self.records_panel.setVisible(True)
|
||||
row = self.record_model.row_for_id(self._selected_record_id or "")
|
||||
if row >= 0:
|
||||
self.record_view.setCurrentIndex(self.record_model.index(row, 0))
|
||||
self.record_view.verticalScrollBar().setValue(self._saved_scroll)
|
||||
self.record_view.setFocus()
|
||||
|
||||
@Slot()
|
||||
def _escape(self) -> None:
|
||||
# Qt popup/menu 优先消费 Esc;只有详情态的页面级 shortcut 会执行返回。
|
||||
if self.left_stack.currentIndex() == self.DETAIL_PAGE:
|
||||
self.return_action.trigger()
|
||||
|
||||
@Slot(object)
|
||||
def _show_record_menu(self, point: object) -> None:
|
||||
index = self.record_view.indexAt(point)
|
||||
if index.isValid():
|
||||
self.record_view.setCurrentIndex(index.siblingAtColumn(0))
|
||||
self._on_record_selected(index)
|
||||
menu = QMenu(self.record_view)
|
||||
menu.addAction(self.view_record_action)
|
||||
menu.exec(self.record_view.viewport().mapToGlobal(point))
|
||||
|
||||
def resizeEvent(self, event) -> None:
|
||||
super().resizeEvent(event)
|
||||
self._apply_compact_detail()
|
||||
|
||||
def _apply_compact_detail(self) -> None:
|
||||
compact_detail = self.width() < self.COMPACT_DETAIL_WIDTH and self.left_stack.currentIndex() == self.DETAIL_PAGE
|
||||
self.records_panel.setVisible(not compact_detail)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""采购工具固定双 Tab 原生窗口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QTimer, Qt, Slot
|
||||
from PySide6.QtGui import QCloseEvent
|
||||
from PySide6.QtWidgets import QMainWindow, QTabWidget
|
||||
|
||||
from cmbuyer_client.localstate.models import ProfileSettings
|
||||
from cmbuyer_client.polling.coordinator import PollingCoordinator, PollingState, RecoveryStatus
|
||||
|
||||
from .execution import ExecutionPage
|
||||
from .records import PurchaseRecordProvider
|
||||
from .settings import ProfileStore, SettingsPage
|
||||
|
||||
|
||||
class PurchaseToolWindow(QMainWindow):
|
||||
EXECUTION_PAGE_ID = "purchase-execution"
|
||||
SETTINGS_PAGE_ID = "settings"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
store: ProfileStore,
|
||||
coordinator: PollingCoordinator,
|
||||
profile_settings: ProfileSettings | None,
|
||||
has_stored_device_token: bool,
|
||||
record_provider: PurchaseRecordProvider | None = None,
|
||||
parent=None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("采购工具")
|
||||
self.setAccessibleName("采购工具")
|
||||
self.setMinimumSize(720, 520)
|
||||
self.resize(1180, 760)
|
||||
self._coordinator = coordinator
|
||||
self._close_pending = False
|
||||
|
||||
recovery = coordinator.recovery_status
|
||||
frozen = recovery is None or recovery.has_pending_claim or recovery.has_active_claim
|
||||
self.tabs = QTabWidget()
|
||||
self.tabs.setObjectName("mainTabs")
|
||||
self.tabs.setTabsClosable(False)
|
||||
self.tabs.setMovable(False)
|
||||
self.execution_page = ExecutionPage(coordinator, record_provider)
|
||||
self.execution_page.setProperty("pageId", self.EXECUTION_PAGE_ID)
|
||||
self.settings_page = SettingsPage(
|
||||
store,
|
||||
settings=profile_settings,
|
||||
has_stored_device_token=has_stored_device_token,
|
||||
identity_frozen=frozen,
|
||||
)
|
||||
self.settings_page.setProperty("pageId", self.SETTINGS_PAGE_ID)
|
||||
self.tabs.addTab(self.execution_page, "采购执行")
|
||||
self.tabs.addTab(self.settings_page, "配置")
|
||||
self.tabs.setCurrentWidget(self.execution_page)
|
||||
self.setCentralWidget(self.tabs)
|
||||
|
||||
self.settings_page.settings_saved.connect(
|
||||
lambda settings, has_token: self._coordinator.update_profile_settings(settings)
|
||||
)
|
||||
coordinator.recovery_status_changed.connect(self._on_recovery_status)
|
||||
coordinator.configuration_freeze_changed.connect(self.settings_page.set_identity_frozen)
|
||||
coordinator.settled.connect(self._finish_pending_close)
|
||||
|
||||
@Slot(object)
|
||||
def _on_recovery_status(self, raw: object) -> None:
|
||||
if not isinstance(raw, RecoveryStatus):
|
||||
self.settings_page.set_identity_frozen(True)
|
||||
return
|
||||
self.settings_page.set_identity_frozen(raw.has_pending_claim or raw.has_active_claim)
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None:
|
||||
running = self._coordinator.state in (
|
||||
PollingState.STARTING,
|
||||
PollingState.RECOVERING,
|
||||
PollingState.WAITING,
|
||||
PollingState.CLAIMING,
|
||||
PollingState.ACTIVE,
|
||||
) or self._coordinator.operation_in_flight
|
||||
if running:
|
||||
# 不 terminate 飞行中的 QThread。先提升 stop latch,等待 HTTP 自身
|
||||
# 超时和 DurableClientGateway 落库,再由 settled 重试关闭。
|
||||
self._close_pending = True
|
||||
self._coordinator.stop()
|
||||
event.ignore()
|
||||
return
|
||||
event.accept()
|
||||
|
||||
@Slot()
|
||||
def _finish_pending_close(self) -> None:
|
||||
if not self._close_pending:
|
||||
return
|
||||
self._close_pending = False
|
||||
QTimer.singleShot(0, self.close)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""采购记录的只读 Qt Model/View 数据源。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol
|
||||
|
||||
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
|
||||
|
||||
from cmbuyer_client.logging_policy import redact_text
|
||||
from cmbuyer_client.core.errors import ValidationError
|
||||
from cmbuyer_client.core.validation import rfc3339_z_nanoseconds
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PurchaseRecord:
|
||||
record_id: str
|
||||
title: str
|
||||
status: str
|
||||
created_at: str
|
||||
original_text: str = ""
|
||||
result_text: str = ""
|
||||
image_description: str = ""
|
||||
created_at_nanoseconds: int = field(init=False, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# 记录 provider 只能注入可显示摘要;最终 UI 边界仍统一脱敏,避免
|
||||
# consumer bug 把 Bearer/裸 token 放入 model、详情或可见日志。
|
||||
for field in ("title", "status", "original_text", "result_text", "image_description"):
|
||||
object.__setattr__(self, field, redact_text(getattr(self, field)))
|
||||
if not isinstance(self.created_at, str):
|
||||
raise ValueError("noncanonical_record_timestamp")
|
||||
fraction = self.created_at[20:-1] if len(self.created_at) > 20 and self.created_at.endswith("Z") else ""
|
||||
if fraction and fraction.endswith("0"):
|
||||
raise ValueError("noncanonical_record_timestamp")
|
||||
try:
|
||||
timestamp = rfc3339_z_nanoseconds(self.created_at)
|
||||
except ValidationError:
|
||||
raise ValueError("noncanonical_record_timestamp") from None
|
||||
object.__setattr__(self, "created_at_nanoseconds", timestamp)
|
||||
|
||||
|
||||
class PurchaseRecordProvider(Protocol):
|
||||
"""只返回已准备好的无秘密 View DTO;不得在 GUI 线程查询 SQLite/HTTP。"""
|
||||
|
||||
def snapshot(self) -> list[PurchaseRecord]: ...
|
||||
|
||||
|
||||
class PurchaseRecordModel(QAbstractTableModel):
|
||||
RECORD_ID_ROLE = int(Qt.ItemDataRole.UserRole) + 1
|
||||
|
||||
def __init__(self, records: list[PurchaseRecord] | None = None, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._records: list[PurchaseRecord] = []
|
||||
self.set_records(records or [])
|
||||
|
||||
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
||||
return 0 if parent.isValid() else len(self._records)
|
||||
|
||||
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
||||
return 0 if parent.isValid() else 2
|
||||
|
||||
def data(self, index: QModelIndex, role: int = int(Qt.ItemDataRole.DisplayRole)):
|
||||
if not index.isValid() or not 0 <= index.row() < len(self._records):
|
||||
return None
|
||||
record = self._records[index.row()]
|
||||
if role == int(Qt.ItemDataRole.DisplayRole):
|
||||
return record.title if index.column() == 0 else record.status
|
||||
if role == self.RECORD_ID_ROLE:
|
||||
return record.record_id
|
||||
if role == int(Qt.ItemDataRole.ToolTipRole):
|
||||
return f"{record.title}\n{record.created_at}"
|
||||
if role == int(Qt.ItemDataRole.TextAlignmentRole) and index.column() == 1:
|
||||
return int(Qt.AlignmentFlag.AlignCenter)
|
||||
return None
|
||||
|
||||
def headerData(self, section: int, orientation: Qt.Orientation, role: int = int(Qt.ItemDataRole.DisplayRole)):
|
||||
if role != int(Qt.ItemDataRole.DisplayRole) or orientation != Qt.Orientation.Horizontal:
|
||||
return None
|
||||
return ("标题", "状态")[section] if 0 <= section < 2 else None
|
||||
|
||||
def set_records(self, records: list[PurchaseRecord]) -> None:
|
||||
self.beginResetModel()
|
||||
self._records = sorted(
|
||||
records,
|
||||
key=lambda item: (item.created_at_nanoseconds, item.record_id),
|
||||
reverse=True,
|
||||
)
|
||||
self.endResetModel()
|
||||
|
||||
def record_at(self, row: int) -> PurchaseRecord | None:
|
||||
return self._records[row] if 0 <= row < len(self._records) else None
|
||||
|
||||
def row_for_id(self, record_id: str) -> int:
|
||||
return next((row for row, item in enumerate(self._records) if item.record_id == record_id), -1)
|
||||
@@ -0,0 +1,249 @@
|
||||
"""只做本地校验和显式保存的配置页。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from PySide6.QtCore import Qt, Signal, Slot
|
||||
from PySide6.QtGui import QAction, QKeySequence
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QFormLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from cmbuyer_client.core.models import SecretToken
|
||||
from cmbuyer_client.core.errors import ValidationError
|
||||
from cmbuyer_client.core.validation import require_uuid4
|
||||
from cmbuyer_client.localstate.models import LOOPBACK_SERVICE_URL, ProfileSettings
|
||||
|
||||
|
||||
class ProfileStore(Protocol):
|
||||
def save_profile(self, settings: ProfileSettings, token: SecretToken | None) -> None: ...
|
||||
|
||||
|
||||
class SettingsPage(QScrollArea):
|
||||
settings_saved = Signal(object, bool)
|
||||
VALIDATION_HINT = "服务身份将在首次真实领取时验证;设备与 App 状态由后续已取证执行能力验证。"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: ProfileStore,
|
||||
*,
|
||||
profile_id: str = "default",
|
||||
settings: ProfileSettings | None = None,
|
||||
has_stored_device_token: bool = False,
|
||||
identity_frozen: bool = False,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("settingsPage")
|
||||
self.setWidgetResizable(True)
|
||||
self._store = store
|
||||
self._profile_id = profile_id
|
||||
self._has_stored_device_token = has_stored_device_token
|
||||
self._loaded_settings = settings
|
||||
self._identity_frozen = identity_frozen
|
||||
|
||||
content = QWidget()
|
||||
outer = QVBoxLayout(content)
|
||||
title = QLabel("配置")
|
||||
title.setObjectName("settingsTitle")
|
||||
title.setStyleSheet("font-size: 20px; font-weight: 600;")
|
||||
outer.addWidget(title)
|
||||
|
||||
form = QFormLayout()
|
||||
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
|
||||
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
outer.addLayout(form)
|
||||
|
||||
self.service_url = QLineEdit(LOOPBACK_SERVICE_URL)
|
||||
self.service_url.setReadOnly(True)
|
||||
self.service_url.setObjectName("serviceUrl")
|
||||
form.addRow("采购服务 URL", self.service_url)
|
||||
|
||||
self.device_id = QLineEdit()
|
||||
self.device_id.setObjectName("deviceId")
|
||||
self.device_id.setPlaceholderText("小写 UUIDv4")
|
||||
form.addRow("设备 UUID", self.device_id)
|
||||
|
||||
self.device_token = QLineEdit()
|
||||
self.device_token.setObjectName("deviceToken")
|
||||
self.device_token.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
# 控件只负责给输入设置合理上限;长度与字符集必须在 save() 中显式
|
||||
# 验证。若这里限制为 64,粘贴 65 位 token 会被 Qt 静默截成合法
|
||||
# 64 位并覆盖原凭据。
|
||||
self.device_token.setMaxLength(256)
|
||||
self.device_token.setPlaceholderText("首次必填;已有凭据时留空表示保留")
|
||||
form.addRow("设备 token", self.device_token)
|
||||
|
||||
self.token_status = QLabel()
|
||||
self.token_status.setObjectName("tokenStatus")
|
||||
form.addRow("凭据状态", self.token_status)
|
||||
|
||||
self.adb_path = QLineEdit()
|
||||
self.adb_path.setObjectName("adbPath")
|
||||
form.addRow("ADB 路径", self.adb_path)
|
||||
|
||||
self.adb_serial = QLineEdit()
|
||||
self.adb_serial.setObjectName("adbSerial")
|
||||
form.addRow("设备 serial", self.adb_serial)
|
||||
|
||||
self.transport = QComboBox()
|
||||
self.transport.setObjectName("transport")
|
||||
self.transport.addItem("USB", "usb")
|
||||
self.transport.addItem("WiFi", "wifi")
|
||||
form.addRow("连接方式", self.transport)
|
||||
|
||||
self.poll_interval = self._spin(5, 300, 15, "pollInterval", " 秒")
|
||||
form.addRow("轮询间隔", self.poll_interval)
|
||||
self.failure_threshold = self._spin(1, 10, 3, "failureThreshold", " 次")
|
||||
form.addRow("连续失败停止阈值", self.failure_threshold)
|
||||
self.http_timeout = self._spin(1, 120, 10, "httpTimeout", " 秒")
|
||||
form.addRow("HTTP 超时", self.http_timeout)
|
||||
self.step_timeout = self._spin(5, 300, 45, "stepTimeout", " 秒")
|
||||
form.addRow("真机步骤超时", self.step_timeout)
|
||||
|
||||
self.validation_hint = QLabel(self.VALIDATION_HINT)
|
||||
self.validation_hint.setObjectName("deferredValidationHint")
|
||||
self.validation_hint.setWordWrap(True)
|
||||
outer.addWidget(self.validation_hint)
|
||||
|
||||
self.feedback = QLabel()
|
||||
self.feedback.setObjectName("settingsFeedback")
|
||||
self.feedback.setWordWrap(True)
|
||||
self.feedback.setAccessibleName("配置保存状态")
|
||||
outer.addWidget(self.feedback)
|
||||
|
||||
self.save_action = QAction("保存配置", self)
|
||||
self.save_action.setShortcut(QKeySequence.StandardKey.Save)
|
||||
self.save_action.triggered.connect(self.save)
|
||||
self.addAction(self.save_action)
|
||||
self.save_button = QPushButton("保存配置")
|
||||
self.save_button.setObjectName("saveSettings")
|
||||
self.save_button.clicked.connect(self.save_action.trigger)
|
||||
outer.addWidget(self.save_button, 0, Qt.AlignmentFlag.AlignRight)
|
||||
outer.addStretch(1)
|
||||
self.setWidget(content)
|
||||
|
||||
if settings is not None:
|
||||
self._load(settings)
|
||||
self._update_token_status()
|
||||
self.set_identity_frozen(identity_frozen)
|
||||
|
||||
@staticmethod
|
||||
def _spin(minimum: int, maximum: int, value: int, name: str, suffix: str) -> QSpinBox:
|
||||
field = QSpinBox()
|
||||
field.setObjectName(name)
|
||||
field.setRange(minimum, maximum)
|
||||
field.setValue(value)
|
||||
field.setSuffix(suffix)
|
||||
return field
|
||||
|
||||
def _load(self, settings: ProfileSettings) -> None:
|
||||
self.device_id.setText(settings.device_id)
|
||||
self.adb_path.setText(settings.adb_path)
|
||||
self.adb_serial.setText(settings.adb_serial)
|
||||
self.transport.setCurrentIndex(max(0, self.transport.findData(settings.transport)))
|
||||
self.poll_interval.setValue(settings.poll_interval_seconds)
|
||||
self.failure_threshold.setValue(settings.failure_threshold)
|
||||
self.http_timeout.setValue(settings.http_timeout_seconds)
|
||||
self.step_timeout.setValue(settings.step_timeout_seconds)
|
||||
|
||||
def set_identity_frozen(self, frozen: bool) -> None:
|
||||
self._identity_frozen = frozen
|
||||
for field in (
|
||||
self.device_id,
|
||||
self.adb_path,
|
||||
self.adb_serial,
|
||||
self.transport,
|
||||
self.poll_interval,
|
||||
self.failure_threshold,
|
||||
self.http_timeout,
|
||||
self.step_timeout,
|
||||
):
|
||||
field.setEnabled(not frozen)
|
||||
if frozen:
|
||||
self.feedback.setText("存在待恢复或执行中的领取;服务与设备身份参数已冻结。")
|
||||
elif self.feedback.text().startswith("存在待恢复或执行中的领取"):
|
||||
self.feedback.clear()
|
||||
|
||||
@Slot()
|
||||
def save(self) -> None:
|
||||
self.feedback.clear()
|
||||
token_text = self.device_token.text().strip()
|
||||
device_id = self.device_id.text().strip()
|
||||
try:
|
||||
require_uuid4(device_id, "invalid_device_id")
|
||||
except (TypeError, ValueError, ValidationError):
|
||||
self._validation_error(self.device_id, "设备 UUID 必须是小写 UUIDv4。")
|
||||
return
|
||||
if not self._has_stored_device_token and not token_text:
|
||||
self._validation_error(self.device_token, "首次保存必须填写设备 token。")
|
||||
return
|
||||
if token_text and (len(token_text) != 64 or any(character not in "0123456789abcdef" for character in token_text)):
|
||||
self._validation_error(self.device_token, "设备 token 必须为 64 位小写十六进制。")
|
||||
return
|
||||
try:
|
||||
if self._identity_frozen:
|
||||
if self._loaded_settings is None:
|
||||
self._validation_error(self.device_id, "冻结配置缺少原始设置,不能保存。")
|
||||
return
|
||||
settings = self._loaded_settings
|
||||
else:
|
||||
adb_text = self.adb_path.text().strip()
|
||||
if not adb_text:
|
||||
self._validation_error(self.adb_path, "请填写 ADB 路径。")
|
||||
return
|
||||
adb_file = Path(adb_text).expanduser()
|
||||
if not adb_file.is_file():
|
||||
self._validation_error(self.adb_path, "ADB 路径必须指向本机已存在的文件。")
|
||||
return
|
||||
adb_file = adb_file.resolve(strict=True)
|
||||
serial = self.adb_serial.text().strip()
|
||||
if not serial:
|
||||
self._validation_error(self.adb_serial, "请填写设备 serial。")
|
||||
return
|
||||
settings = ProfileSettings(
|
||||
profile_id=self._profile_id,
|
||||
service_url=LOOPBACK_SERVICE_URL,
|
||||
device_id=device_id,
|
||||
adb_path=str(adb_file),
|
||||
adb_serial=serial,
|
||||
transport=str(self.transport.currentData()),
|
||||
poll_interval_seconds=self.poll_interval.value(),
|
||||
failure_threshold=self.failure_threshold.value(),
|
||||
http_timeout_seconds=self.http_timeout.value(),
|
||||
step_timeout_seconds=self.step_timeout.value(),
|
||||
)
|
||||
token = SecretToken(token_text) if token_text else None
|
||||
except (TypeError, ValueError):
|
||||
self._validation_error(self.device_id, "配置格式不正确,请检查设备 UUID 与各项参数。")
|
||||
return
|
||||
try:
|
||||
# None 明确表示保留 T-303 中已有的 DPAPI 密文,绝不是清除凭据。
|
||||
self._store.save_profile(settings, token)
|
||||
except Exception:
|
||||
self.feedback.setText("配置保存失败,本地安全存储未更新。")
|
||||
(self.device_token if token_text else self.device_id).setFocus()
|
||||
return
|
||||
self._has_stored_device_token = True
|
||||
self._loaded_settings = settings
|
||||
self.device_token.clear()
|
||||
self._update_token_status()
|
||||
self.feedback.setText("配置已保存。本地校验不代表服务、设备或 App 已就绪。")
|
||||
self.settings_saved.emit(settings, True)
|
||||
|
||||
def _validation_error(self, field: QWidget, message: str) -> None:
|
||||
self.feedback.setText(message)
|
||||
field.setFocus()
|
||||
|
||||
def _update_token_status(self) -> None:
|
||||
self.token_status.setText("已保存" if self._has_stored_device_token else "未保存")
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from hashlib import sha256
|
||||
from io import BytesIO
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
@@ -48,6 +50,16 @@ def _hash(path: Path) -> str:
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _source_png(size: tuple[int, int]) -> bytes:
|
||||
image = Image.new("RGB", size, color=(0, 180, 0))
|
||||
if size == (EXPECTED_SCREENSHOT_WIDTH, EXPECTED_SCREENSHOT_HEIGHT):
|
||||
image.paste((255, 0, 0), (0, 0, 8, 540))
|
||||
output = BytesIO()
|
||||
image.save(output, format="PNG")
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _default_xml() -> str:
|
||||
return (
|
||||
"<hierarchy rotation='0'>"
|
||||
@@ -116,12 +128,7 @@ def _write_raw(
|
||||
raw = root / "raw"
|
||||
raw.mkdir(parents=True)
|
||||
screenshot = raw / "screenshot.png"
|
||||
image = Image.new("RGB", size, color=(0, 180, 0))
|
||||
if size == (EXPECTED_SCREENSHOT_WIDTH, EXPECTED_SCREENSHOT_HEIGHT):
|
||||
for y in range(540):
|
||||
for x in range(8):
|
||||
image.putpixel((x, y), (255, 0, 0))
|
||||
image.save(screenshot, format="PNG")
|
||||
screenshot.write_bytes(_source_png(size))
|
||||
hierarchy = raw / "hierarchy.xml"
|
||||
hierarchy.write_text(_default_xml() if xml is None else xml, encoding="utf-8")
|
||||
manifest = {
|
||||
|
||||
@@ -46,6 +46,15 @@ class FakeProtector:
|
||||
return plaintext
|
||||
|
||||
|
||||
class NoUnprotectProtector(FakeProtector):
|
||||
def __init__(self) -> None:
|
||||
self.unprotect_calls = 0
|
||||
|
||||
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes:
|
||||
self.unprotect_calls += 1
|
||||
raise AssertionError("metadata_read_must_not_unprotect")
|
||||
|
||||
|
||||
def settings(device_id: str = DEVICE_ID) -> ProfileSettings:
|
||||
return ProfileSettings(
|
||||
PROFILE,
|
||||
@@ -112,6 +121,106 @@ class LocalStateStoreTests(unittest.TestCase):
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
def test_profile_summary_reads_settings_and_token_presence_without_unprotect(self) -> None:
|
||||
protector = NoUnprotectProtector()
|
||||
summary_store = LocalStateStore(
|
||||
self.database,
|
||||
protector,
|
||||
now=lambda: self.clock[0],
|
||||
)
|
||||
summary = summary_store.load_profile_summary(PROFILE)
|
||||
self.assertEqual(summary.settings, settings())
|
||||
self.assertTrue(summary.has_stored_device_token)
|
||||
self.assertNotIn(DEVICE_TOKEN, repr(summary))
|
||||
self.assertEqual(protector.unprotect_calls, 0)
|
||||
|
||||
def test_profile_summary_empty_cipher_fails_closed_without_unprotect(self) -> None:
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute(
|
||||
"UPDATE profiles SET device_token_cipher=? WHERE profile_id=?",
|
||||
(sqlite3.Binary(b""), PROFILE),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
protector = NoUnprotectProtector()
|
||||
summary_store = LocalStateStore(self.database, protector, now=lambda: self.clock[0])
|
||||
with self.assertRaisesRegex(StateError, "invalid_device_token_cipher"):
|
||||
summary_store.load_profile_summary(PROFILE)
|
||||
self.assertEqual(protector.unprotect_calls, 0)
|
||||
|
||||
def test_profile_summary_wrong_cipher_storage_class_fails_closed_without_unprotect(self) -> None:
|
||||
current = settings()
|
||||
row = (
|
||||
current.service_url,
|
||||
current.device_id,
|
||||
current.adb_path,
|
||||
current.adb_serial,
|
||||
current.transport,
|
||||
current.poll_interval_seconds,
|
||||
current.failure_threshold,
|
||||
current.http_timeout_seconds,
|
||||
current.step_timeout_seconds,
|
||||
"text",
|
||||
64,
|
||||
)
|
||||
protector = NoUnprotectProtector()
|
||||
summary_store = LocalStateStore(self.database, protector, now=lambda: self.clock[0])
|
||||
fake_connection = mock.Mock()
|
||||
fake_connection.execute.return_value.fetchone.return_value = row
|
||||
with mock.patch.object(summary_store, "_read_transaction") as read_transaction:
|
||||
read_transaction.return_value.__enter__.return_value = fake_connection
|
||||
with self.assertRaisesRegex(StateError, "invalid_device_token_cipher"):
|
||||
summary_store.load_profile_summary(PROFILE)
|
||||
self.assertEqual(protector.unprotect_calls, 0)
|
||||
|
||||
def test_profile_summary_invalid_stored_settings_are_normalized_without_unprotect(self) -> None:
|
||||
original = settings()
|
||||
protector = NoUnprotectProtector()
|
||||
summary_store = LocalStateStore(self.database, protector, now=lambda: self.clock[0])
|
||||
for column, invalid, valid in (
|
||||
("service_url", "http://127.0.0.1:9999", original.service_url),
|
||||
("transport", "bluetooth", original.transport),
|
||||
("poll_interval_seconds", 4, original.poll_interval_seconds),
|
||||
):
|
||||
with self.subTest(column=column):
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute("PRAGMA ignore_check_constraints=ON")
|
||||
connection.execute(
|
||||
f"UPDATE profiles SET {column}=? WHERE profile_id=?",
|
||||
(invalid, PROFILE),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
try:
|
||||
with self.assertRaisesRegex(StateError, "stored_profile_invalid") as captured:
|
||||
summary_store.load_profile_summary(PROFILE)
|
||||
self.assertNotIn(DEVICE_TOKEN, str(captured.exception))
|
||||
finally:
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
connection.execute("PRAGMA ignore_check_constraints=ON")
|
||||
connection.execute(
|
||||
f"UPDATE profiles SET {column}=? WHERE profile_id=?",
|
||||
(valid, PROFILE),
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertEqual(protector.unprotect_calls, 0)
|
||||
|
||||
def test_profile_summary_missing_profile_fails_without_creating_defaults(self) -> None:
|
||||
with self.assertRaisesRegex(StateError, "profile_not_found"):
|
||||
self.store.load_profile_summary("missing")
|
||||
connection = sqlite3.connect(self.database)
|
||||
try:
|
||||
self.assertEqual(connection.execute("SELECT count(*) FROM profiles").fetchone()[0], 1)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_empty_allows_new_key_but_terminal_does_not(self) -> None:
|
||||
self.store.start_or_resume_polling(PROFILE)
|
||||
first = self.store.prepare_claim(PROFILE)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<hierarchy>
|
||||
<!-- 商品内容区同名促销小字:它不是规格入口,保留为防误点反例。 -->
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,1256][1080,1355]" clickable="true" enabled="true" visible-to-user="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.ViewSwitcher" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true">
|
||||
@@ -8,7 +9,9 @@
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[446,2166][1080,2328]" clickable="true" enabled="true" visible-to-user="true">
|
||||
<!-- 只允许点击底部购买区第一行文本叶节点中心;第二行只用于精确结构校验。 -->
|
||||
<node content-desc="快要抢光¥12.88" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[446,2166][1080,2328]" clickable="true" enabled="true" visible-to-user="true">
|
||||
<node text="快要抢光 ¥ 12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[688,2184][1042,2253]" clickable="false" enabled="true" visible-to-user="true" />
|
||||
<node text="免拼购买" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[688,2256][856,2305]" clickable="false" enabled="true" visible-to-user="true" />
|
||||
</node>
|
||||
</hierarchy>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<hierarchy rotation="0">
|
||||
<!-- T-104 同商品正锚:只保留人工确认 post-exit XML 中的标题容器及两个直接文本子节点。 -->
|
||||
<node resource-id="com.xunmeng.pinduoduo:id/tv_title" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1571][1044,1689]" content-desc="2026年新款高档重工潮流烫钻中长款T恤显瘦宽松上衣淡人穿搭" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" long-clickable="true">
|
||||
<node text="2026年新款高档重工潮流烫钻中长款T恤显瘦宽松" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1571][1023,1624]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node text="上衣淡人穿搭" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1636][306,1689]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<!-- 同一份 post-exit XML 中的底部规格入口;只证明页面身份,不在安全退出中点击。 -->
|
||||
<node content-desc="快要抢光¥12.88" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[446,2166][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="快要抢光 ¥ 12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[688,2184][1042,2253]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node text="免拼购买" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[688,2256][856,2305]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -0,0 +1,30 @@
|
||||
<hierarchy rotation="0">
|
||||
<!-- T-105 只保留人工确认数量 1 证据中的规格、顶部金额与数量控件。 -->
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[0,474][1080,863]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,498][1053,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,498][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="快卖完 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,503][712,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
<node text="已选: 黑色 CHA (纯棉) M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,654][1053,716]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" resource-id="com.xunmeng.pinduoduo:id/gnl" bounds="[396,752][645,827]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,752][645,827]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node content-desc="减少数量" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,752][474,827]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node text="1" package="com.xunmeng.pinduoduo" class="android.widget.EditText" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[480,752][561,827]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<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" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[126,1000][438,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[126,1000][438,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/tv_content" bounds="[126,1000][438,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
</node>
|
||||
<!-- 底部金额故意保留为反例;生产读取器只认上方证据角色。 -->
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[0,2181][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="提交订单 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[369,2225][710,2284]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -0,0 +1,30 @@
|
||||
<hierarchy rotation="0">
|
||||
<!-- T-105 只保留人工确认数量 2 证据中的规格、顶部金额与数量控件。 -->
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[0,474][1080,861]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,498][1053,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,498][740,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="快卖完 ¥32.76" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,503][722,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
<node text="已选: 黑色 CHA (纯棉) M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,582][1053,644]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" resource-id="com.xunmeng.pinduoduo:id/gnl" bounds="[396,750][645,825]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,750][645,825]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node content-desc="减少数量" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,750][474,825]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node text="2" package="com.xunmeng.pinduoduo" class="android.widget.EditText" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[480,750][561,825]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node content-desc="增加数量" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[567,750][645,825]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[126,998][438,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[126,998][438,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/tv_content" bounds="[126,998][438,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
</node>
|
||||
<!-- 与 Gate2 值相同也不能成为读取候选。 -->
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[0,2181][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="提交订单 ¥32.76" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[366,2225][714,2284]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1077]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,575][912,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="限1件 ¥12.88 " package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,580][675,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node text="券前¥29.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[693,580][912,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node text="请选择: 尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,731][1053,793]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1077][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,1188]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="颜色分类" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1106][192,1159]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1188][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1188][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1188][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][684,1500]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node content-desc="打开大图" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][483,1299]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1479][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1479][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,2052][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,2052][1044,2079]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,2069][114,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1077]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.RelativeLayout" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[396,575][1053,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,575][693,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="限1件 ¥12.88 " package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,580][675,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
<node text="请选择: 尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,731][1053,793]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1077][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,1188]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="颜色分类" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1106][192,1159]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1188][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1188][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1188][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][684,1500]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node content-desc="打开大图" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][483,1299]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1479][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1479][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,2052][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,2052][1044,2079]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,2069][114,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1077]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,575][912,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="限1件 ¥12.88 " package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,580][675,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node text="券前¥29.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[693,580][912,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node text="请选择: 颜色分类 尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,731][1053,793]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1077][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,1188]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="颜色分类" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1106][192,1159]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1188][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1188][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1188][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][684,1500]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node content-desc="打开大图" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][483,1299]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1479][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1479][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,2052][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,2052][1044,2079]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,2069][114,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1077]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.RelativeLayout" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[396,575][1053,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,575][693,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="限1件 ¥12.88 " package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,580][675,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
<node text="请选择: 颜色分类 尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,731][1053,793]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1077][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,1188]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="颜色分类" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1106][192,1159]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1188][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1188][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1188][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][684,1500]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node content-desc="打开大图" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][483,1299]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1479][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1479][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,2052][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,2052][1044,2079]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,2069][114,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1000]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,498][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="快卖完 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,503][712,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node text="¥29.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[730,503][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node text="已选: 黑色 CHA (纯棉) M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,654][1053,716]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1000][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1000][1080,1489]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1000][1080,1489]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1000][1080,1483]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1000][1080,1483]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1000][684,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1000][684,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1000][684,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1000][684,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1489][1080,1930]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1489][1044,1570]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1506][114,1552]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1582][1044,1897]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1582][1044,1897]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1582][409,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="S(建议80-100)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1582][409,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[439,1582][831,1667]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1000]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,498][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="快卖完 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,503][712,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node text="¥29.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[730,503][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node text="已选: 黑色 CHA (纯棉) S(建议80-100)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,654][1053,716]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1000][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1000][1080,1489]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1000][1080,1489]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1000][1080,1483]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1000][1080,1483]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1000][684,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1000][684,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1000][684,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1000][684,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1489][1080,1930]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1489][1044,1570]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1506][114,1552]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1582][1044,1897]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1582][1044,1897]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1582][409,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="S(建议80-100)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1582][409,1667]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[36,1582][409,1667]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -0,0 +1,597 @@
|
||||
"""T-106 确认页四态纯只读取证的离线测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import base64
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from functools import lru_cache
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from io import BytesIO, StringIO
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbDevice, CommandResult, DeviceInspection
|
||||
from cmbuyer_client.pdd.order_confirm_spike import (
|
||||
Android16ForegroundReader,
|
||||
DECLARED_STATES,
|
||||
EXPECTED_GOODS_ID,
|
||||
OrderConfirmEvidenceCapturer,
|
||||
OrderConfirmEvidenceError,
|
||||
OrderConfirmEvidenceTimeoutError,
|
||||
OrderConfirmForegroundReader,
|
||||
OrderConfirmReadDevice,
|
||||
)
|
||||
|
||||
|
||||
SERIAL = "192.168.0.173:5555"
|
||||
HIERARCHY = (
|
||||
"<?xml version='1.0' encoding='UTF-8'?><hierarchy rotation='0'>"
|
||||
"<node text='local raw page' /></hierarchy>"
|
||||
)
|
||||
TOP_RESUMED = (
|
||||
" topResumedActivity=ActivityRecord{101034589 u0 "
|
||||
"com.xunmeng.pinduoduo/.activity.NewPageActivity t1816}\n"
|
||||
)
|
||||
|
||||
|
||||
@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 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("unexpected read RPC")
|
||||
|
||||
|
||||
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 else self.package
|
||||
activity = self.post_activity if len(self.calls) > 1 and self.post_activity 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
|
||||
|
||||
|
||||
def _load_script() -> object:
|
||||
path = CLIENT_ROOT / "scripts" / "capture_order_confirm_spike.py"
|
||||
specification = spec_from_file_location("capture_order_confirm_spike_for_test", path)
|
||||
assert specification is not None and specification.loader is not None
|
||||
module = module_from_spec(specification)
|
||||
specification.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _namespace(**changes: object) -> argparse.Namespace:
|
||||
values: dict[str, object] = {
|
||||
"serial": SERIAL,
|
||||
"goods_id": EXPECTED_GOODS_ID,
|
||||
"state": DECLARED_STATES[0],
|
||||
"output_dir": Path("evidence"),
|
||||
"timeout": 10.0,
|
||||
"adb": "adb",
|
||||
}
|
||||
values.update(changes)
|
||||
return argparse.Namespace(**values)
|
||||
|
||||
|
||||
class Android16ForegroundReaderTests(unittest.TestCase):
|
||||
def test_reads_exact_unique_top_resumed_activity(self) -> None:
|
||||
runner = FakeCommandRunner(CommandResult(stdout=TOP_RESUMED))
|
||||
reader = Android16ForegroundReader(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_duplicate_or_failed_summary_is_rejected(self) -> None:
|
||||
outputs = (
|
||||
"",
|
||||
"mResumedActivity: ActivityRecord{1 u0 com.xunmeng.pinduoduo/.Main t1}\n",
|
||||
TOP_RESUMED + TOP_RESUMED,
|
||||
"topResumedActivity=null\n",
|
||||
)
|
||||
for output in outputs:
|
||||
with self.subTest(output=output):
|
||||
reader = Android16ForegroundReader(
|
||||
FakeCommandRunner(CommandResult(stdout=output)),
|
||||
timeout_seconds=7,
|
||||
)
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
reader.read(SERIAL)
|
||||
|
||||
reader = Android16ForegroundReader(
|
||||
FakeCommandRunner(CommandResult(stdout=TOP_RESUMED, returncode=1)),
|
||||
timeout_seconds=7,
|
||||
)
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
reader.read(SERIAL)
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
reader.read(f" {SERIAL}")
|
||||
|
||||
|
||||
class OrderConfirmEvidenceTests(unittest.TestCase):
|
||||
def _capturer(
|
||||
self,
|
||||
adb: FakeAdbClient,
|
||||
device: FakeReadDevice,
|
||||
*,
|
||||
foreground: FakeForegroundReader | None = None,
|
||||
connector_calls: list[str] | None = None,
|
||||
) -> OrderConfirmEvidenceCapturer:
|
||||
def connect(serial: str) -> FakeReadDevice:
|
||||
if connector_calls is not None:
|
||||
connector_calls.append(serial)
|
||||
return device
|
||||
|
||||
return OrderConfirmEvidenceCapturer(
|
||||
adb, # type: ignore[arg-type]
|
||||
connect,
|
||||
foreground or FakeForegroundReader(),
|
||||
timeout_seconds=2,
|
||||
)
|
||||
|
||||
def test_all_four_human_states_publish_only_raw_read_evidence(self) -> None:
|
||||
self.assertEqual(len(DECLARED_STATES), 4)
|
||||
for state in DECLARED_STATES:
|
||||
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeReadDevice()
|
||||
foreground = FakeForegroundReader()
|
||||
connector_calls: list[str] = []
|
||||
target = Path(temporary) / state
|
||||
|
||||
result = self._capturer(
|
||||
adb,
|
||||
device,
|
||||
foreground=foreground,
|
||||
connector_calls=connector_calls,
|
||||
).capture(SERIAL, EXPECTED_GOODS_ID, state, target)
|
||||
|
||||
manifest_text = result.manifest_path.read_text(encoding="utf-8")
|
||||
manifest = json.loads(manifest_text)
|
||||
app = json.loads(result.app_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(adb.calls, [SERIAL])
|
||||
self.assertEqual(connector_calls, [SERIAL])
|
||||
self.assertEqual(foreground.calls, [SERIAL, SERIAL])
|
||||
self.assertEqual(
|
||||
[call[1] for call in device.calls if call[0] == "jsonrpc"],
|
||||
["takeScreenshot", "dumpWindowHierarchy"],
|
||||
)
|
||||
self.assertEqual(manifest["operation"], "t106-order-confirm-readonly-evidence")
|
||||
self.assertEqual(manifest["human_declared_state"], state)
|
||||
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"},
|
||||
)
|
||||
self.assertNotIn(SERIAL, manifest_text)
|
||||
self.assertNotIn("NewPageActivity", manifest_text)
|
||||
self.assertNotIn("local raw page", manifest_text)
|
||||
for artifact in manifest["artifacts"]:
|
||||
self.assertEqual(len(artifact["sha256"]), 64)
|
||||
|
||||
def test_invalid_inputs_and_existing_target_stop_before_device_access(self) -> None:
|
||||
scenarios = (
|
||||
("", EXPECTED_GOODS_ID, DECLARED_STATES[0]),
|
||||
(f" {SERIAL}", EXPECTED_GOODS_ID, DECLARED_STATES[0]),
|
||||
(SERIAL, "958756616606", DECLARED_STATES[0]),
|
||||
(SERIAL, EXPECTED_GOODS_ID, "unknown"),
|
||||
)
|
||||
for serial, goods_id, state in scenarios:
|
||||
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||
adb = FakeAdbClient()
|
||||
connector_calls: list[str] = []
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
self._capturer(
|
||||
adb,
|
||||
FakeReadDevice(),
|
||||
connector_calls=connector_calls,
|
||||
).capture(serial, goods_id, state, 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(OrderConfirmEvidenceError):
|
||||
self._capturer(adb, FakeReadDevice()).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], target
|
||||
)
|
||||
self.assertEqual(adb.calls, [])
|
||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
||||
|
||||
def test_device_version_package_activity_and_screen_mismatch_fail_closed(self) -> None:
|
||||
scenarios = (
|
||||
(FakeAdbClient(model="OTHER"), FakeReadDevice(), 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()),
|
||||
)
|
||||
for adb, device, foreground in scenarios:
|
||||
with self.subTest(device=device.__dict__), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
self._capturer(adb, device, foreground=foreground).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], 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_never_publishes(self) -> None:
|
||||
devices = (
|
||||
FakeReadDevice(screenshot="not base64"),
|
||||
FakeReadDevice(screenshot=123),
|
||||
FakeReadDevice(hierarchy="<not-hierarchy />"),
|
||||
FakeReadDevice(hierarchy=123),
|
||||
)
|
||||
for device in devices:
|
||||
with self.subTest(value=device.screenshot), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
self._capturer(FakeAdbClient(), device).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], target
|
||||
)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_timeout_and_unexpected_failure_are_redacted(self) -> None:
|
||||
class BrokenDevice(FakeReadDevice):
|
||||
def __init__(self, failure: BaseException) -> None:
|
||||
super().__init__()
|
||||
self.failure = failure
|
||||
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> object:
|
||||
raise self.failure
|
||||
|
||||
failures = (
|
||||
(TimeoutError(f"secret {SERIAL} C:\\private\\raw.xml"), OrderConfirmEvidenceTimeoutError),
|
||||
(RuntimeError(f"secret {SERIAL} C:\\private\\raw.xml"), OrderConfirmEvidenceError),
|
||||
)
|
||||
for failure, expected in failures:
|
||||
with self.subTest(expected=expected), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(expected) as raised:
|
||||
self._capturer(FakeAdbClient(), BrokenDevice(failure)).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], 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_identity_drift_never_publishes(self) -> None:
|
||||
mutators = (
|
||||
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)),
|
||||
)
|
||||
for mutate in mutators:
|
||||
with TemporaryDirectory() as temporary:
|
||||
device = FakeReadDevice()
|
||||
foreground = FakeForegroundReader()
|
||||
mutate(device, foreground)
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
self._capturer(
|
||||
FakeAdbClient(), device, foreground=foreground
|
||||
).capture(SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], target)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_atomic_publish_failure_cleans_staging(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with (
|
||||
patch(
|
||||
"cmbuyer_client.pdd.order_confirm_spike.os.rename",
|
||||
side_effect=OSError(f"private {SERIAL}"),
|
||||
),
|
||||
self.assertRaises(OrderConfirmEvidenceError) as raised,
|
||||
):
|
||||
self._capturer(FakeAdbClient(), FakeReadDevice()).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], target
|
||||
)
|
||||
self.assertNotIn(SERIAL, str(raised.exception))
|
||||
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(OrderConfirmEvidenceError):
|
||||
capturer.capture("", EXPECTED_GOODS_ID, DECLARED_STATES[0], Path(temporary) / "bad")
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
capturer.capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], Path(temporary) / "good"
|
||||
)
|
||||
|
||||
def test_public_boundaries_expose_reading_only(self) -> None:
|
||||
forbidden = {
|
||||
"click",
|
||||
"swipe",
|
||||
"press",
|
||||
"pressKey",
|
||||
"send_keys",
|
||||
"set_text",
|
||||
"start_pdd_view_intent",
|
||||
"open_product",
|
||||
"open_sku_panel",
|
||||
"set_quantity",
|
||||
"go_to_order_confirm",
|
||||
"submit_order_once",
|
||||
"pay",
|
||||
}
|
||||
self.assertTrue(forbidden.isdisjoint(OrderConfirmReadDevice.__dict__))
|
||||
self.assertTrue(forbidden.isdisjoint(OrderConfirmForegroundReader.__dict__))
|
||||
self.assertEqual(
|
||||
{name for name in Android16ForegroundReader.__dict__ if not name.startswith("_")},
|
||||
{"read"},
|
||||
)
|
||||
self.assertEqual(
|
||||
{name for name in OrderConfirmEvidenceCapturer.__dict__ if not name.startswith("_")},
|
||||
{"capture"},
|
||||
)
|
||||
|
||||
|
||||
class OrderConfirmCliAndStaticBoundaryTests(unittest.TestCase):
|
||||
def test_cli_parses_each_approved_state(self) -> None:
|
||||
script = _load_script()
|
||||
for state in DECLARED_STATES:
|
||||
arguments = script.parse_arguments( # type: ignore[attr-defined]
|
||||
[
|
||||
"--serial",
|
||||
SERIAL,
|
||||
"--goods-id",
|
||||
EXPECTED_GOODS_ID,
|
||||
"--state",
|
||||
state,
|
||||
"--output-dir",
|
||||
f"evidence-{state}",
|
||||
]
|
||||
)
|
||||
script.validate_arguments(arguments) # type: ignore[attr-defined]
|
||||
|
||||
def test_cli_validation_rejects_unapproved_values(self) -> None:
|
||||
script = _load_script()
|
||||
invalid = (
|
||||
_namespace(serial=""),
|
||||
_namespace(serial=f" {SERIAL}"),
|
||||
_namespace(goods_id="958756616606"),
|
||||
_namespace(state="unknown"),
|
||||
_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_runtime_failure_never_echoes_sensitive_values(self) -> None:
|
||||
script = _load_script()
|
||||
fake_capturer = unittest.mock.Mock()
|
||||
fake_capturer.capture.side_effect = OrderConfirmEvidenceError(
|
||||
f"secret {SERIAL} C:\\private\\raw.xml body"
|
||||
)
|
||||
stderr = StringIO()
|
||||
with (
|
||||
patch.object(script, "OrderConfirmEvidenceCapturer", return_value=fake_capturer),
|
||||
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,
|
||||
"--state",
|
||||
DECLARED_STATES[0],
|
||||
"--output-dir",
|
||||
"C:\\private\\evidence",
|
||||
]
|
||||
)
|
||||
self.assertEqual(result, 1)
|
||||
self.assertNotIn(SERIAL, stderr.getvalue())
|
||||
self.assertNotIn("private", stderr.getvalue())
|
||||
self.assertNotIn("body", stderr.getvalue())
|
||||
|
||||
def test_cli_success_does_not_echo_local_path_or_device(self) -> None:
|
||||
script = _load_script()
|
||||
fake_capturer = unittest.mock.Mock()
|
||||
stdout = StringIO()
|
||||
with (
|
||||
patch.object(script, "OrderConfirmEvidenceCapturer", return_value=fake_capturer),
|
||||
patch.dict(
|
||||
sys.modules,
|
||||
{"adbutils": unittest.mock.Mock(), "uiautomator2": unittest.mock.Mock()},
|
||||
),
|
||||
redirect_stdout(stdout),
|
||||
):
|
||||
result = script.main( # type: ignore[attr-defined]
|
||||
[
|
||||
"--serial",
|
||||
SERIAL,
|
||||
"--goods-id",
|
||||
EXPECTED_GOODS_ID,
|
||||
"--state",
|
||||
DECLARED_STATES[0],
|
||||
"--output-dir",
|
||||
"C:\\private\\evidence",
|
||||
]
|
||||
)
|
||||
self.assertEqual(result, 0)
|
||||
self.assertNotIn(SERIAL, stdout.getvalue())
|
||||
self.assertNotIn("private", stdout.getvalue())
|
||||
fake_capturer.capture.assert_called_once()
|
||||
|
||||
def test_sources_have_only_approved_read_rpc_literals_and_no_ui_mutators(self) -> None:
|
||||
expected_by_source = {
|
||||
Path("src/cmbuyer_client/pdd/order_confirm_spike.py"): {
|
||||
"takeScreenshot",
|
||||
"dumpWindowHierarchy",
|
||||
},
|
||||
Path("scripts/capture_order_confirm_spike.py"): set(),
|
||||
}
|
||||
forbidden_attributes = {
|
||||
"click",
|
||||
"swipe",
|
||||
"press",
|
||||
"pressKey",
|
||||
"send_keys",
|
||||
"set_text",
|
||||
"start_pdd_view_intent",
|
||||
"open_product",
|
||||
"open_sku_panel",
|
||||
"set_quantity_and_readback",
|
||||
"go_to_order_confirm",
|
||||
"submit_order_once",
|
||||
}
|
||||
forbidden_import_fragments = {
|
||||
"quantity_gate2_runner",
|
||||
"sku_selection_runner",
|
||||
"submission",
|
||||
"payment",
|
||||
}
|
||||
for relative, expected_rpc in expected_by_source.items():
|
||||
source = (CLIENT_ROOT / relative).read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
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_rpc)
|
||||
observed_attributes = {
|
||||
node.attr
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Attribute) and node.attr in forbidden_attributes
|
||||
}
|
||||
self.assertEqual(observed_attributes, set())
|
||||
imported_modules = {
|
||||
alias.name
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom))
|
||||
for alias in node.names
|
||||
}
|
||||
for fragment in forbidden_import_fragments:
|
||||
self.assertTrue(all(fragment not in module for module in imported_modules))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,944 @@
|
||||
"""T-105 数量两态只读取证与后续生产边界的离线测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import base64
|
||||
from contextlib import redirect_stderr
|
||||
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 json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbDevice, CommandResult, DeviceInspection
|
||||
from cmbuyer_client.pdd.quantity_gate2_spike import (
|
||||
Android16TopResumedForegroundReader,
|
||||
DECLARED_QUANTITIES,
|
||||
EXPECTED_GOODS_ID,
|
||||
QuantityGate2EvidenceCapturer,
|
||||
QuantityGate2EvidenceError,
|
||||
QuantityGate2EvidenceTimeoutError,
|
||||
QuantityGate2ForegroundReader,
|
||||
QuantityGate2ReadDevice,
|
||||
)
|
||||
from cmbuyer_client.pdd.quantity_gate2 import (
|
||||
Gate1Observation,
|
||||
QuantityGate2Device,
|
||||
QuantityGate2Error,
|
||||
QuantityGate2Flow,
|
||||
QuantityGate2OverCapError,
|
||||
)
|
||||
from cmbuyer_client.pdd.quantity_gate2_runner import QuantityGate2Runner
|
||||
|
||||
|
||||
SERIAL = "192.168.0.173:5555"
|
||||
HIERARCHY = "<?xml version='1.0' encoding='UTF-8'?><hierarchy rotation='0'><node text='local raw page' /></hierarchy>"
|
||||
TOP_RESUMED = (
|
||||
" topResumedActivity=ActivityRecord{101034589 u0 "
|
||||
"com.xunmeng.pinduoduo/.activity.NewPageActivity t1816}\n"
|
||||
)
|
||||
|
||||
|
||||
@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}")
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _load_script() -> object:
|
||||
script_path = CLIENT_ROOT / "scripts" / "capture_quantity_gate2_spike.py"
|
||||
spec = spec_from_file_location("capture_quantity_gate2_spike_for_test", script_path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _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
|
||||
|
||||
|
||||
def _namespace(**changes: object) -> argparse.Namespace:
|
||||
values: dict[str, object] = {
|
||||
"serial": SERIAL,
|
||||
"goods_id": EXPECTED_GOODS_ID,
|
||||
"state": "initial",
|
||||
"declared_quantity": 1,
|
||||
"output_dir": Path("evidence"),
|
||||
"timeout": 10.0,
|
||||
"adb": "adb",
|
||||
}
|
||||
values.update(changes)
|
||||
return argparse.Namespace(**values)
|
||||
|
||||
|
||||
class 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)
|
||||
|
||||
|
||||
class QuantityGate2EvidenceTests(unittest.TestCase):
|
||||
def _capturer(
|
||||
self,
|
||||
adb: FakeAdbClient,
|
||||
device: FakeReadDevice,
|
||||
*,
|
||||
foreground: FakeForegroundReader | None = None,
|
||||
connector_calls: list[str] | None = None,
|
||||
) -> QuantityGate2EvidenceCapturer:
|
||||
def connect(serial: str) -> FakeReadDevice:
|
||||
if connector_calls is not None:
|
||||
connector_calls.append(serial)
|
||||
return device
|
||||
|
||||
return QuantityGate2EvidenceCapturer(
|
||||
adb,
|
||||
connect,
|
||||
foreground or FakeForegroundReader(),
|
||||
timeout_seconds=2,
|
||||
)
|
||||
|
||||
def test_initial_and_target_states_publish_only_raw_read_evidence(self) -> None:
|
||||
for state, quantity in DECLARED_QUANTITIES.items():
|
||||
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeReadDevice()
|
||||
foreground = FakeForegroundReader()
|
||||
connector_calls: list[str] = []
|
||||
target = Path(temporary) / state
|
||||
|
||||
result = self._capturer(
|
||||
adb,
|
||||
device,
|
||||
foreground=foreground,
|
||||
connector_calls=connector_calls,
|
||||
).capture(
|
||||
SERIAL,
|
||||
EXPECTED_GOODS_ID,
|
||||
state,
|
||||
quantity,
|
||||
target,
|
||||
)
|
||||
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
app = json.loads(result.app_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(adb.calls, [SERIAL])
|
||||
self.assertEqual(connector_calls, [SERIAL])
|
||||
self.assertEqual(foreground.calls, [SERIAL, SERIAL])
|
||||
self.assertEqual(
|
||||
[call[1] for call in device.calls if call[0] == "jsonrpc"],
|
||||
["takeScreenshot", "dumpWindowHierarchy"],
|
||||
)
|
||||
self.assertEqual(manifest["operation"], "t105-quantity-gate2-readonly-evidence")
|
||||
self.assertEqual(manifest["human_declared_state"], state)
|
||||
self.assertEqual(manifest["human_declared_quantity"], quantity)
|
||||
self.assertEqual(
|
||||
manifest["human_declared_selection"],
|
||||
{"color": "黑色CHA(纯棉)", "size": "M(建议100-115)"},
|
||||
)
|
||||
self.assertEqual(manifest["review_status"], "human_review_required")
|
||||
self.assertEqual(manifest["product"]["goods_id"], EXPECTED_GOODS_ID)
|
||||
self.assertEqual(app["package"], "com.xunmeng.pinduoduo")
|
||||
self.assertEqual(
|
||||
{path.name for path in target.iterdir()},
|
||||
{"screenshot.png", "hierarchy.xml", "app.json", "manifest.json"},
|
||||
)
|
||||
serialized = result.manifest_path.read_text(encoding="utf-8")
|
||||
self.assertNotIn(SERIAL, serialized)
|
||||
self.assertNotIn("NewPageActivity", serialized)
|
||||
self.assertNotIn("local raw page", serialized)
|
||||
for artifact in manifest["artifacts"]:
|
||||
self.assertEqual(len(artifact["sha256"]), 64)
|
||||
|
||||
def test_invalid_inputs_and_existing_target_fail_before_device_access(self) -> None:
|
||||
scenarios = (
|
||||
("", EXPECTED_GOODS_ID, "initial", 1),
|
||||
(f" {SERIAL}", EXPECTED_GOODS_ID, "initial", 1),
|
||||
(SERIAL, "958756616606", "initial", 1),
|
||||
(SERIAL, EXPECTED_GOODS_ID, "unknown", 1),
|
||||
(SERIAL, EXPECTED_GOODS_ID, "initial", 2),
|
||||
(SERIAL, EXPECTED_GOODS_ID, "target", 1),
|
||||
(SERIAL, EXPECTED_GOODS_ID, "initial", True),
|
||||
)
|
||||
for serial, goods_id, state, quantity in scenarios:
|
||||
with self.subTest(state=state, quantity=quantity), TemporaryDirectory() as temporary:
|
||||
adb = FakeAdbClient()
|
||||
connector_calls: list[str] = []
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
self._capturer(adb, FakeReadDevice(), connector_calls=connector_calls).capture(
|
||||
serial,
|
||||
goods_id,
|
||||
state,
|
||||
quantity,
|
||||
Path(temporary) / "evidence",
|
||||
)
|
||||
self.assertEqual(adb.calls, [])
|
||||
self.assertEqual(connector_calls, [])
|
||||
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
target.mkdir()
|
||||
sentinel = target / "sentinel.txt"
|
||||
sentinel.write_text("keep", encoding="utf-8")
|
||||
adb = FakeAdbClient()
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
self._capturer(adb, FakeReadDevice()).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, "initial", 1, target
|
||||
)
|
||||
self.assertEqual(adb.calls, [])
|
||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
||||
|
||||
def test_device_version_package_activity_and_screen_mismatch_fail_closed(self) -> None:
|
||||
scenarios = (
|
||||
(FakeAdbClient(model="OTHER"), FakeReadDevice(), 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()),
|
||||
)
|
||||
for adb, device, foreground in scenarios:
|
||||
with self.subTest(device=device.__dict__), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
self._capturer(adb, device, foreground=foreground).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, "initial", 1, target
|
||||
)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
self.assertEqual(
|
||||
[call for call in device.calls if call[0] == "jsonrpc"],
|
||||
[],
|
||||
)
|
||||
|
||||
def test_invalid_screenshot_or_hierarchy_leaves_no_partial_evidence(self) -> None:
|
||||
scenarios = (
|
||||
FakeReadDevice(screenshot="not base64"),
|
||||
FakeReadDevice(screenshot=123),
|
||||
FakeReadDevice(hierarchy="<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 = (
|
||||
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)),
|
||||
)
|
||||
for mutate in drift_mutators:
|
||||
with TemporaryDirectory() as temporary:
|
||||
device = FakeReadDevice()
|
||||
foreground = FakeForegroundReader()
|
||||
mutate(device, foreground)
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
self._capturer(
|
||||
FakeAdbClient(),
|
||||
device,
|
||||
foreground=foreground,
|
||||
).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, "initial", 1, target
|
||||
)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_capturer_is_one_shot_even_after_rejection(self) -> None:
|
||||
capturer = self._capturer(FakeAdbClient(), FakeReadDevice())
|
||||
with TemporaryDirectory() as temporary:
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
capturer.capture("", EXPECTED_GOODS_ID, "initial", 1, Path(temporary) / "bad")
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
capturer.capture(SERIAL, EXPECTED_GOODS_ID, "initial", 1, Path(temporary) / "good")
|
||||
|
||||
def test_read_protocol_and_capturer_expose_no_page_actions(self) -> None:
|
||||
forbidden = {
|
||||
"click",
|
||||
"swipe",
|
||||
"press",
|
||||
"pressKey",
|
||||
"send_keys",
|
||||
"set_text",
|
||||
"open_product",
|
||||
"open_sku_panel",
|
||||
"set_quantity",
|
||||
"go_to_order_confirm",
|
||||
"submit_order_once",
|
||||
"pay",
|
||||
}
|
||||
self.assertTrue(forbidden.isdisjoint(QuantityGate2ReadDevice.__dict__))
|
||||
self.assertTrue(forbidden.isdisjoint(QuantityGate2ForegroundReader.__dict__))
|
||||
self.assertEqual(
|
||||
{
|
||||
name
|
||||
for name in Android16TopResumedForegroundReader.__dict__
|
||||
if not name.startswith("_")
|
||||
},
|
||||
{"read"},
|
||||
)
|
||||
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"))
|
||||
|
||||
|
||||
_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())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
+1797
-112
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
"""轮询协调器测试。"""
|
||||
@@ -0,0 +1,380 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from cmbuyer_client.core.errors import (
|
||||
AmbiguousRemoteError,
|
||||
CredentialRemoteError,
|
||||
ManualRemoteError,
|
||||
ProtocolRemoteError,
|
||||
StateError,
|
||||
)
|
||||
from cmbuyer_client.core.models import ClaimRequest, ClaimedTask
|
||||
from cmbuyer_client.localstate.models import PollingSession, ProfileSettings, RecoverySnapshot
|
||||
from cmbuyer_client.polling.coordinator import (
|
||||
ClaimedTaskView,
|
||||
PollingCoordinator,
|
||||
PollingState,
|
||||
StartReadiness,
|
||||
)
|
||||
from tests.core.test_models import claim_wire
|
||||
|
||||
|
||||
PROFILE = "default"
|
||||
SESSION_ID = "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
REQUEST_ID = "23c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
SENTINEL_TOKEN = "e" * 64
|
||||
|
||||
|
||||
def profile(http_timeout: int = 10, failure_threshold: int = 3) -> ProfileSettings:
|
||||
return ProfileSettings(
|
||||
PROFILE,
|
||||
"http://127.0.0.1:8080",
|
||||
"f3c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"D:/Portable/adb/adb.exe",
|
||||
"device-serial",
|
||||
"usb",
|
||||
failure_threshold=failure_threshold,
|
||||
http_timeout_seconds=http_timeout,
|
||||
)
|
||||
|
||||
|
||||
def claimed_task() -> ClaimedTask:
|
||||
wire = claim_wire()
|
||||
wire["attempt"]["claim_token"] = SENTINEL_TOKEN
|
||||
return ClaimedTask.from_wire(wire)
|
||||
|
||||
|
||||
def snapshot(
|
||||
*,
|
||||
accept_new: bool | None = None,
|
||||
pending: bool = False,
|
||||
active: bool = False,
|
||||
) -> RecoverySnapshot:
|
||||
session = None if accept_new is None else PollingSession(PROFILE, SESSION_ID, accept_new)
|
||||
request = ClaimRequest(SESSION_ID, REQUEST_ID) if pending else None
|
||||
return RecoverySnapshot(session, request, claimed_task() if active else None, None, ())
|
||||
|
||||
|
||||
class FakeStore:
|
||||
def __init__(self, current: RecoverySnapshot) -> None:
|
||||
self.current = current
|
||||
self.recovery_calls = 0
|
||||
self.start_calls = 0
|
||||
self.stop_calls = 0
|
||||
|
||||
def recovery_snapshot(self, profile_id: str) -> RecoverySnapshot:
|
||||
self.recovery_calls += 1
|
||||
return self.current
|
||||
|
||||
def start_or_resume_polling(self, profile_id: str) -> PollingSession:
|
||||
self.start_calls += 1
|
||||
session = self.current.session or PollingSession(PROFILE, SESSION_ID, True)
|
||||
session = PollingSession(PROFILE, session.session_id, True)
|
||||
self.current = RecoverySnapshot(
|
||||
session,
|
||||
self.current.pending_claim,
|
||||
self.current.active_claim,
|
||||
self.current.pending_renew,
|
||||
self.current.pending_evidence,
|
||||
)
|
||||
return session
|
||||
|
||||
def request_stop(self, profile_id: str) -> PollingSession:
|
||||
self.stop_calls += 1
|
||||
if self.current.session is None:
|
||||
raise StateError("polling_session_not_found")
|
||||
session = PollingSession(PROFILE, self.current.session.session_id, False)
|
||||
self.current = RecoverySnapshot(
|
||||
session,
|
||||
self.current.pending_claim,
|
||||
self.current.active_claim,
|
||||
self.current.pending_renew,
|
||||
self.current.pending_evidence,
|
||||
)
|
||||
return session
|
||||
|
||||
|
||||
class FakeGateway:
|
||||
def __init__(self, outcomes: list[object] | None = None, gate: threading.Event | None = None) -> None:
|
||||
self.outcomes = list(outcomes or [None])
|
||||
self.gate = gate
|
||||
self.entered = threading.Event()
|
||||
self.calls = 0
|
||||
|
||||
def claim_next(self, profile_id: str):
|
||||
self.calls += 1
|
||||
self.entered.set()
|
||||
if self.gate is not None:
|
||||
if not self.gate.wait(5):
|
||||
raise RuntimeError("test_gateway_release_timeout")
|
||||
outcome = self.outcomes.pop(0) if self.outcomes else None
|
||||
if isinstance(outcome, Exception):
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
|
||||
class FakeConsumer:
|
||||
def __init__(self) -> None:
|
||||
self.claims: list[ClaimedTask] = []
|
||||
self.profiles: list[ProfileSettings] = []
|
||||
|
||||
def accept_claim(self, claimed: ClaimedTask, profile: ProfileSettings) -> None:
|
||||
self.claims.append(claimed)
|
||||
self.profiles.append(profile)
|
||||
|
||||
|
||||
def wait_until(predicate, timeout_ms: int = 2000) -> None:
|
||||
elapsed = 0
|
||||
while not predicate() and elapsed < timeout_ms:
|
||||
# worker 仍通过真实 Qt 队列信号推进,因此测试必须持续处理事件;
|
||||
# 1 ms 量子既会让出 QThread,又不会把每次状态转换放大成 10 ms 等待。
|
||||
QTest.qWait(1)
|
||||
elapsed += 1
|
||||
if not predicate():
|
||||
raise AssertionError("condition_not_reached")
|
||||
|
||||
|
||||
class PollingCoordinatorTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.app = QApplication.instance() or QApplication([])
|
||||
|
||||
def make_coordinator(
|
||||
self,
|
||||
store: FakeStore,
|
||||
gateway: FakeGateway | None,
|
||||
consumer: FakeConsumer | None,
|
||||
*,
|
||||
readiness: StartReadiness | None = StartReadiness(True, "ready"),
|
||||
settings: ProfileSettings | None = None,
|
||||
threshold: int = 3,
|
||||
interval_ms: int = 5,
|
||||
) -> tuple[PollingCoordinator, list[ProfileSettings]]:
|
||||
frozen: list[ProfileSettings] = []
|
||||
|
||||
def factory(value: ProfileSettings):
|
||||
frozen.append(value)
|
||||
assert gateway is not None
|
||||
return gateway
|
||||
|
||||
coordinator = PollingCoordinator(
|
||||
profile_id=PROFILE,
|
||||
store=store,
|
||||
gateway_factory=None if gateway is None else factory,
|
||||
consumer=consumer,
|
||||
profile_settings=settings or profile(failure_threshold=threshold),
|
||||
readiness=readiness,
|
||||
failure_threshold=threshold,
|
||||
timer_interval_ms=interval_ms,
|
||||
)
|
||||
self.addCleanup(lambda: self.assertTrue(coordinator.shutdown()))
|
||||
wait_until(lambda: not coordinator.operation_in_flight)
|
||||
return coordinator, frozen
|
||||
|
||||
def test_restart_normalizes_waiting_pending_and_active_without_http(self) -> None:
|
||||
for name, current, expected in (
|
||||
("waiting", snapshot(accept_new=True), PollingState.STOPPED),
|
||||
("pending", snapshot(accept_new=True, pending=True), PollingState.STOPPED),
|
||||
("active", snapshot(accept_new=True, active=True), PollingState.RECOVERY_REQUIRED),
|
||||
):
|
||||
with self.subTest(name=name):
|
||||
store = FakeStore(current)
|
||||
gateway = FakeGateway()
|
||||
consumer = FakeConsumer()
|
||||
coordinator, frozen = self.make_coordinator(store, gateway, consumer)
|
||||
self.assertEqual(coordinator.state, expected)
|
||||
self.assertFalse(store.current.session.accept_new)
|
||||
self.assertEqual(store.stop_calls, 1)
|
||||
self.assertEqual(gateway.calls, 0)
|
||||
self.assertEqual(frozen, [])
|
||||
|
||||
def test_missing_consumer_direct_start_is_zero_gateway_and_zero_session_start(self) -> None:
|
||||
store = FakeStore(snapshot())
|
||||
gateway = FakeGateway()
|
||||
coordinator, frozen = self.make_coordinator(store, gateway, None)
|
||||
coordinator.start()
|
||||
self.assertEqual(coordinator.state, PollingState.BLOCKED)
|
||||
self.assertIn("尚未接入", coordinator.reason)
|
||||
self.assertEqual(store.start_calls, 0)
|
||||
self.assertEqual(gateway.calls, 0)
|
||||
self.assertEqual(frozen, [])
|
||||
|
||||
def test_readiness_is_rechecked_inside_start_handler(self) -> None:
|
||||
store = FakeStore(snapshot())
|
||||
gateway = FakeGateway()
|
||||
coordinator, frozen = self.make_coordinator(
|
||||
store,
|
||||
gateway,
|
||||
FakeConsumer(),
|
||||
readiness=StartReadiness(False, "设备尚未就绪"),
|
||||
)
|
||||
coordinator.start()
|
||||
self.assertEqual(coordinator.state, PollingState.BLOCKED)
|
||||
self.assertEqual(store.start_calls, 0)
|
||||
self.assertEqual(gateway.calls, 0)
|
||||
self.assertEqual(frozen, [])
|
||||
|
||||
def test_empty_waits_then_stop_persists_accept_new_false(self) -> None:
|
||||
store = FakeStore(snapshot())
|
||||
gateway = FakeGateway([None])
|
||||
coordinator, frozen = self.make_coordinator(store, gateway, FakeConsumer(), interval_ms=1000)
|
||||
coordinator.start()
|
||||
wait_until(lambda: coordinator.state == PollingState.WAITING and gateway.calls == 1)
|
||||
self.assertEqual(frozen[0].http_timeout_seconds, 10)
|
||||
coordinator.stop()
|
||||
wait_until(lambda: coordinator.state == PollingState.STOPPED and not coordinator.operation_in_flight)
|
||||
self.assertFalse(store.current.session.accept_new)
|
||||
self.assertEqual(gateway.calls, 1)
|
||||
|
||||
def test_explicit_start_that_observes_stale_accept_new_stops_without_http(self) -> None:
|
||||
store = FakeStore(snapshot())
|
||||
gateway = FakeGateway([None])
|
||||
coordinator, frozen = self.make_coordinator(store, gateway, FakeConsumer())
|
||||
store.current = snapshot(accept_new=True)
|
||||
coordinator.start()
|
||||
wait_until(lambda: coordinator.state == PollingState.STOPPED and not coordinator.operation_in_flight)
|
||||
self.assertFalse(store.current.session.accept_new)
|
||||
self.assertEqual(gateway.calls, 0)
|
||||
self.assertEqual(len(frozen), 1)
|
||||
self.assertIn("再次显式开始", coordinator.reason)
|
||||
|
||||
def test_stop_during_claim_commits_then_requires_recovery_without_consumer_delivery(self) -> None:
|
||||
gate = threading.Event()
|
||||
store = FakeStore(snapshot())
|
||||
gateway = FakeGateway([claimed_task()], gate)
|
||||
consumer = FakeConsumer()
|
||||
coordinator, _ = self.make_coordinator(store, gateway, consumer)
|
||||
visible: list[object] = []
|
||||
coordinator.claim_visible.connect(visible.append)
|
||||
coordinator.start()
|
||||
# CLAIMING 状态先于 queued worker 实际进入 claim_next;必须等待
|
||||
# worker 入口,避免与 FakeGateway 的超时边界竞争而产生套件级偶发失败。
|
||||
wait_until(lambda: coordinator.state == PollingState.CLAIMING and gateway.entered.is_set())
|
||||
coordinator._begin_claim(coordinator._epoch)
|
||||
self.assertEqual(gateway.calls, 1)
|
||||
coordinator.stop()
|
||||
old_epoch = coordinator._epoch - 1
|
||||
coordinator._begin_claim(old_epoch)
|
||||
gate.set()
|
||||
wait_until(lambda: coordinator.state == PollingState.RECOVERY_REQUIRED and not coordinator.operation_in_flight)
|
||||
self.assertEqual(consumer.claims, [])
|
||||
self.assertEqual(gateway.calls, 1)
|
||||
self.assertFalse(store.current.session.accept_new)
|
||||
self.assertEqual(len(visible), 1)
|
||||
self.assertIsInstance(visible[0], ClaimedTaskView)
|
||||
self.assertNotIn(SENTINEL_TOKEN, repr(visible[0]))
|
||||
|
||||
def test_only_reason_whitelist_gets_automatic_same_gateway_retry(self) -> None:
|
||||
store = FakeStore(snapshot(accept_new=False, pending=True))
|
||||
gateway = FakeGateway(
|
||||
[AmbiguousRemoteError("http_result_unknown"), AmbiguousRemoteError("truncated_response")]
|
||||
)
|
||||
coordinator, _ = self.make_coordinator(store, gateway, FakeConsumer(), threshold=2)
|
||||
coordinator.start()
|
||||
wait_until(lambda: coordinator.state == PollingState.BLOCKED and not coordinator.operation_in_flight)
|
||||
self.assertEqual(gateway.calls, 2)
|
||||
self.assertEqual(store.current.pending_claim.claim_request_id, REQUEST_ID)
|
||||
self.assertFalse(store.current.session.accept_new)
|
||||
|
||||
def test_schema_ambiguity_blocks_after_one_call_and_preserves_pending(self) -> None:
|
||||
for reason in (
|
||||
"unknown_success_status",
|
||||
"invalid_claim_success_response",
|
||||
"invalid_claim_lease",
|
||||
"ambiguous_response_framing",
|
||||
"invalid_content_length",
|
||||
"response_too_large",
|
||||
):
|
||||
with self.subTest(reason=reason):
|
||||
store = FakeStore(snapshot(accept_new=False, pending=True))
|
||||
gateway = FakeGateway([AmbiguousRemoteError(reason)])
|
||||
coordinator, _ = self.make_coordinator(store, gateway, FakeConsumer())
|
||||
coordinator.start()
|
||||
wait_until(lambda: coordinator.state == PollingState.BLOCKED and not coordinator.operation_in_flight)
|
||||
self.assertEqual(gateway.calls, 1)
|
||||
self.assertEqual(store.current.pending_claim.claim_request_id, REQUEST_ID)
|
||||
|
||||
def test_credential_manual_and_local_failures_do_not_enter_timer_retry(self) -> None:
|
||||
for error, expected_frozen in (
|
||||
(CredentialRemoteError("invalid_device_credentials"), True),
|
||||
(ManualRemoteError("claim_requires_manual"), False),
|
||||
(ProtocolRemoteError("invalid_protocol"), False),
|
||||
(StateError("localstate_integrity_failed"), True),
|
||||
):
|
||||
with self.subTest(error=error.reason):
|
||||
store = FakeStore(snapshot(accept_new=False, pending=True))
|
||||
gateway = FakeGateway([error])
|
||||
coordinator, _ = self.make_coordinator(store, gateway, FakeConsumer())
|
||||
freeze_events: list[bool] = []
|
||||
coordinator.configuration_freeze_changed.connect(freeze_events.append)
|
||||
coordinator.start()
|
||||
wait_until(lambda: coordinator.state == PollingState.BLOCKED and not coordinator.operation_in_flight)
|
||||
self.assertEqual(gateway.calls, 1)
|
||||
self.assertFalse(coordinator._timer.isActive())
|
||||
self.assertIsNone(coordinator._scheduled_epoch)
|
||||
self.assertFalse(store.current.session.accept_new)
|
||||
self.assertEqual(freeze_events[-1], expected_frozen)
|
||||
|
||||
def test_stop_latched_bootstrap_active_emits_settled_for_pending_close(self) -> None:
|
||||
store = FakeStore(snapshot())
|
||||
gateway = FakeGateway()
|
||||
coordinator, _ = self.make_coordinator(store, gateway, FakeConsumer())
|
||||
store.current = snapshot(accept_new=False, active=True)
|
||||
settled: list[bool] = []
|
||||
coordinator.settled.connect(lambda: settled.append(True))
|
||||
coordinator.start()
|
||||
coordinator.stop()
|
||||
wait_until(lambda: coordinator.state == PollingState.RECOVERY_REQUIRED)
|
||||
self.assertTrue(settled)
|
||||
self.assertEqual(gateway.calls, 0)
|
||||
|
||||
def test_each_explicit_start_freezes_profile_for_gateway_factory(self) -> None:
|
||||
store = FakeStore(snapshot())
|
||||
gateway = FakeGateway([None])
|
||||
coordinator, frozen = self.make_coordinator(store, gateway, FakeConsumer(), interval_ms=1000)
|
||||
changed = profile(http_timeout=27)
|
||||
coordinator.update_profile_settings(changed)
|
||||
coordinator.start()
|
||||
wait_until(lambda: gateway.calls == 1)
|
||||
self.assertEqual(frozen, [changed])
|
||||
coordinator.stop()
|
||||
wait_until(lambda: not coordinator.operation_in_flight)
|
||||
|
||||
def test_consumer_receives_start_snapshot_even_if_profile_changes_while_claiming(self) -> None:
|
||||
gate = threading.Event()
|
||||
store = FakeStore(snapshot())
|
||||
gateway = FakeGateway([claimed_task()], gate)
|
||||
consumer = FakeConsumer()
|
||||
original = profile(http_timeout=10)
|
||||
coordinator, frozen = self.make_coordinator(
|
||||
store,
|
||||
gateway,
|
||||
consumer,
|
||||
settings=original,
|
||||
)
|
||||
coordinator.start()
|
||||
wait_until(lambda: coordinator.state == PollingState.CLAIMING and gateway.calls == 1)
|
||||
changed = profile(http_timeout=27)
|
||||
coordinator.update_profile_settings(changed)
|
||||
gate.set()
|
||||
wait_until(lambda: coordinator.state == PollingState.ACTIVE)
|
||||
self.assertEqual(frozen, [original])
|
||||
self.assertEqual(consumer.profiles, [original])
|
||||
self.assertIsNot(consumer.profiles[0], changed)
|
||||
|
||||
def test_claim_view_redacts_sentinel_even_if_title_contains_it(self) -> None:
|
||||
wire = claim_wire()
|
||||
wire["task"]["title"] = "标题 " + SENTINEL_TOKEN
|
||||
wire["attempt"]["claim_token"] = SENTINEL_TOKEN
|
||||
claimed = ClaimedTask.from_wire(wire)
|
||||
view = ClaimedTaskView.from_claim(claimed)
|
||||
self.assertNotIn(SENTINEL_TOKEN, repr(view))
|
||||
self.assertIn("已隐藏", view.title)
|
||||
@@ -22,3 +22,36 @@ class ApplicationArgumentsTests(unittest.TestCase):
|
||||
def test_none_uses_process_arguments(self) -> None:
|
||||
with mock.patch("cmbuyer_client.app.sys.argv", ["process-name", "--process-option"]):
|
||||
self.assertEqual(["process-name", "--process-option"], select_application_argv(None))
|
||||
|
||||
def test_standalone_entry_does_not_construct_http_or_device_capabilities(self) -> None:
|
||||
source = (CLIENT_ROOT / "src" / "cmbuyer_client" / "app.py").read_text(encoding="utf-8")
|
||||
for forbidden in (
|
||||
"HttpTaskSource",
|
||||
"HttpTransport",
|
||||
"DurableClientGateway",
|
||||
"import uiautomator2",
|
||||
".device",
|
||||
".pdd",
|
||||
):
|
||||
with self.subTest(forbidden=forbidden):
|
||||
self.assertNotIn(forbidden, source)
|
||||
self.assertIn("gateway_factory=None", source)
|
||||
self.assertIn("consumer=None", source)
|
||||
|
||||
def test_t304_ui_has_no_hidden_connection_probe_or_irreversible_capability_import(self) -> None:
|
||||
paths = [
|
||||
*(CLIENT_ROOT / "src" / "cmbuyer_client" / "ui").glob("*.py"),
|
||||
*(CLIENT_ROOT / "src" / "cmbuyer_client" / "polling").glob("*.py"),
|
||||
]
|
||||
source = "\n".join(path.read_text(encoding="utf-8") for path in paths)
|
||||
for forbidden in (
|
||||
"uiautomator2",
|
||||
"cmbuyer_client.pdd",
|
||||
"cmbuyer_client.device",
|
||||
"submit_order",
|
||||
"payment",
|
||||
"http.client",
|
||||
"subprocess",
|
||||
):
|
||||
with self.subTest(forbidden=forbidden):
|
||||
self.assertNotIn(forbidden, source)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""原生 Qt Widgets UI 测试。"""
|
||||
@@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import QObject, Qt, Signal
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from cmbuyer_client.polling.coordinator import PollingState, RecoveryStatus
|
||||
from cmbuyer_client.ui.execution import ExecutionPage
|
||||
from cmbuyer_client.ui.main_window import PurchaseToolWindow
|
||||
from cmbuyer_client.ui.records import PurchaseRecord
|
||||
|
||||
|
||||
class FakeCoordinator(QObject):
|
||||
state_changed = Signal(object, str, int)
|
||||
claim_visible = Signal(object)
|
||||
settled = Signal()
|
||||
recovery_status_changed = Signal(object)
|
||||
configuration_freeze_changed = Signal(bool)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.state = PollingState.BLOCKED
|
||||
self.reason = "单趟执行能力尚未接入,不能领取真实任务。"
|
||||
self.consecutive_failures = 0
|
||||
self.can_start = False
|
||||
self.operation_in_flight = False
|
||||
self.recovery_status = RecoveryStatus(False, False, False, False, False)
|
||||
self.starts = 0
|
||||
self.stops = 0
|
||||
|
||||
def start(self) -> None:
|
||||
self.starts += 1
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stops += 1
|
||||
|
||||
def update_profile_settings(self, settings) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class FakeStore:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def save_profile(self, settings, token) -> None:
|
||||
self.calls += 1
|
||||
|
||||
|
||||
def records() -> list[PurchaseRecord]:
|
||||
return [
|
||||
PurchaseRecord("old", "旧记录", "失败", "2026-08-04T09:00:00Z", "旧文字", "旧结果"),
|
||||
PurchaseRecord("new", "新记录", "待付款", "2026-08-04T10:00:00Z", "新文字", "新结果"),
|
||||
]
|
||||
|
||||
|
||||
class ExecutionPageTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.app = QApplication.instance() or QApplication([])
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.coordinator = FakeCoordinator()
|
||||
self.page = ExecutionPage(self.coordinator)
|
||||
self.page.resize(1100, 700)
|
||||
self.page.show()
|
||||
QTest.qWait(20)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.page.close()
|
||||
|
||||
def test_standalone_start_is_disabled_with_nearby_reason_and_empty_real_model(self) -> None:
|
||||
self.assertFalse(self.page.poll_button.isEnabled())
|
||||
self.assertIn("尚未接入", self.page.banner.text())
|
||||
self.assertEqual(self.page.record_model.rowCount(), 0)
|
||||
self.assertEqual(self.page.record_model.columnCount(), 2)
|
||||
self.assertEqual(self.coordinator.starts, 0)
|
||||
|
||||
def test_records_are_newest_first_and_open_via_action_then_escape_restores_focus(self) -> None:
|
||||
self.page.set_records(records())
|
||||
self.assertEqual(self.page.record_model.data(self.page.record_model.index(0, 0)), "新记录")
|
||||
index = self.page.record_model.index(0, 0)
|
||||
self.page.record_view.setCurrentIndex(index)
|
||||
self.page.view_record_action.trigger()
|
||||
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
|
||||
self.assertEqual(self.page.detail_title.text(), "新记录")
|
||||
QTest.keyClick(self.page.return_button, Qt.Key.Key_Escape)
|
||||
QTest.qWait(20)
|
||||
self.assertEqual(self.page.left_stack.currentIndex(), self.page.LIVE_PAGE)
|
||||
self.assertEqual(self.page.record_view.currentIndex().row(), 0)
|
||||
self.assertTrue(self.page.record_view.hasFocus())
|
||||
|
||||
def test_detail_selection_updates_in_place_without_modal(self) -> None:
|
||||
self.page.set_records(records())
|
||||
self.page.record_view.setCurrentIndex(self.page.record_model.index(0, 0))
|
||||
self.page.open_selected_record()
|
||||
second = self.page.record_model.index(1, 0)
|
||||
self.page.record_view.setCurrentIndex(second)
|
||||
self.page._on_record_selected(second)
|
||||
self.assertEqual(self.page.detail_title.text(), "旧记录")
|
||||
self.assertEqual(self.page.detail_result.toPlainText(), "旧结果")
|
||||
|
||||
def test_double_click_enter_and_visible_action_share_inline_detail_command(self) -> None:
|
||||
self.page.set_records(records())
|
||||
index = self.page.record_model.index(0, 0)
|
||||
self.page.record_view.setCurrentIndex(index)
|
||||
rect = self.page.record_view.visualRect(index)
|
||||
QTest.mouseDClick(self.page.record_view.viewport(), Qt.MouseButton.LeftButton, pos=rect.center())
|
||||
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
|
||||
self.assertIsNone(QApplication.activeModalWidget())
|
||||
self.page.return_to_live()
|
||||
self.page.record_view.setFocus()
|
||||
QTest.keyClick(self.page.record_view, Qt.Key.Key_Return)
|
||||
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
|
||||
self.page.return_to_live()
|
||||
self.page.view_record_action.trigger()
|
||||
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
|
||||
|
||||
def test_responsive_resize_keeps_model_selection_and_inline_detail(self) -> None:
|
||||
self.page.set_records(records())
|
||||
model = self.page.record_model
|
||||
self.page.record_view.setCurrentIndex(model.index(0, 0))
|
||||
self.page.open_selected_record()
|
||||
for width in (700, 900, 1200):
|
||||
self.page.resize(width, 700)
|
||||
QTest.qWait(10)
|
||||
self.assertIs(self.page.record_view.model(), model)
|
||||
self.assertEqual(self.page.record_view.currentIndex().row(), 0)
|
||||
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
|
||||
|
||||
def test_record_model_detail_and_log_redact_bare_token_sentinel(self) -> None:
|
||||
token = "e" * 64
|
||||
record = PurchaseRecord("secret", "标题 " + token, "失败", "2026-08-04T10:00:00Z", token, token)
|
||||
self.page.set_records([record])
|
||||
self.page.record_view.setCurrentIndex(self.page.record_model.index(0, 0))
|
||||
self.page.open_selected_record()
|
||||
self.page.log_view.append_event("Bearer " + token)
|
||||
visible = "\n".join(
|
||||
(
|
||||
str(self.page.record_model.data(self.page.record_model.index(0, 0))),
|
||||
self.page.detail_original.toPlainText(),
|
||||
self.page.detail_result.toPlainText(),
|
||||
self.page.log_view.toPlainText(),
|
||||
)
|
||||
)
|
||||
self.assertNotIn(token, visible)
|
||||
self.assertIn("已隐藏", visible)
|
||||
|
||||
|
||||
class MainWindowTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.app = QApplication.instance() or QApplication([])
|
||||
|
||||
def test_fixed_default_tabs_and_close_do_not_start_or_stop_blocked_session(self) -> None:
|
||||
coordinator = FakeCoordinator()
|
||||
window = PurchaseToolWindow(
|
||||
store=FakeStore(),
|
||||
coordinator=coordinator,
|
||||
profile_settings=None,
|
||||
has_stored_device_token=False,
|
||||
)
|
||||
window.show()
|
||||
QTest.qWait(10)
|
||||
self.assertEqual(window.tabs.count(), 2)
|
||||
self.assertEqual([window.tabs.tabText(i) for i in range(2)], ["采购执行", "配置"])
|
||||
self.assertIs(window.tabs.currentWidget(), window.execution_page)
|
||||
self.assertFalse(window.tabs.tabsClosable())
|
||||
window.close()
|
||||
self.assertEqual(coordinator.starts, 0)
|
||||
self.assertEqual(coordinator.stops, 0)
|
||||
|
||||
def test_close_waits_for_inflight_settlement_instead_of_terminating(self) -> None:
|
||||
coordinator = FakeCoordinator()
|
||||
coordinator.state = PollingState.CLAIMING
|
||||
coordinator.operation_in_flight = True
|
||||
window = PurchaseToolWindow(
|
||||
store=FakeStore(),
|
||||
coordinator=coordinator,
|
||||
profile_settings=None,
|
||||
has_stored_device_token=False,
|
||||
)
|
||||
window.show()
|
||||
self.assertFalse(window.close())
|
||||
self.assertTrue(window.isVisible())
|
||||
self.assertEqual(coordinator.stops, 1)
|
||||
coordinator.operation_in_flight = False
|
||||
coordinator.state = PollingState.STOPPED
|
||||
coordinator.settled.emit()
|
||||
QTest.qWait(20)
|
||||
self.assertFalse(window.isVisible())
|
||||
|
||||
def test_recovery_status_freezes_and_unfreezes_non_token_settings(self) -> None:
|
||||
coordinator = FakeCoordinator()
|
||||
window = PurchaseToolWindow(
|
||||
store=FakeStore(),
|
||||
coordinator=coordinator,
|
||||
profile_settings=None,
|
||||
has_stored_device_token=False,
|
||||
)
|
||||
coordinator.recovery_status_changed.emit(RecoveryStatus(True, False, True, False, False))
|
||||
self.assertFalse(window.settings_page.device_id.isEnabled())
|
||||
self.assertTrue(window.settings_page.device_token.isEnabled())
|
||||
coordinator.recovery_status_changed.emit(RecoveryStatus(False, False, False, False, False))
|
||||
self.assertTrue(window.settings_page.device_id.isEnabled())
|
||||
window.close()
|
||||
|
||||
|
||||
class PurchaseRecordTimestampTests(unittest.TestCase):
|
||||
def test_records_sort_by_real_rfc3339_nanoseconds_not_raw_text(self) -> None:
|
||||
precise = [
|
||||
PurchaseRecord("later", "稍后", "完成", "2026-08-04T10:00:00.9Z"),
|
||||
PurchaseRecord("earlier", "稍早", "完成", "2026-08-04T10:00:00.11Z"),
|
||||
]
|
||||
from cmbuyer_client.ui.records import PurchaseRecordModel
|
||||
|
||||
model = PurchaseRecordModel(precise)
|
||||
self.assertEqual(model.record_at(0).record_id, "later")
|
||||
|
||||
def test_record_timestamp_rejects_offset_and_noncanonical_trailing_zero(self) -> None:
|
||||
for timestamp in ("2026-08-04T10:00:00+08:00", "2026-08-04T10:00:00.10Z"):
|
||||
with self.subTest(timestamp=timestamp), self.assertRaises(ValueError):
|
||||
PurchaseRecord("id", "标题", "完成", timestamp)
|
||||
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QPushButton
|
||||
|
||||
from cmbuyer_client.localstate.models import ProfileSettings
|
||||
from cmbuyer_client.ui.settings import SettingsPage
|
||||
|
||||
|
||||
DEVICE_ID = "f3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
TOKEN = "b" * 64
|
||||
|
||||
|
||||
class FakeStore:
|
||||
def __init__(self, error: Exception | None = None) -> None:
|
||||
self.calls: list[tuple[ProfileSettings, object]] = []
|
||||
self.error = error
|
||||
|
||||
def save_profile(self, settings: ProfileSettings, token: object) -> None:
|
||||
self.calls.append((settings, token))
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
|
||||
|
||||
class SettingsPageTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.app = QApplication.instance() or QApplication([])
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.adb = Path(self.directory.name) / "adb.exe"
|
||||
self.adb.touch()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.directory.cleanup()
|
||||
|
||||
def fill(self, page: SettingsPage) -> None:
|
||||
page.device_id.setText(DEVICE_ID)
|
||||
page.adb_path.setText(str(self.adb))
|
||||
page.adb_serial.setText("device-serial")
|
||||
|
||||
def existing_settings(self) -> ProfileSettings:
|
||||
return ProfileSettings(
|
||||
"default",
|
||||
"http://127.0.0.1:8080",
|
||||
DEVICE_ID,
|
||||
str(self.adb),
|
||||
"device-serial",
|
||||
"usb",
|
||||
)
|
||||
|
||||
def test_first_save_requires_token_and_performs_zero_store_write(self) -> None:
|
||||
store = FakeStore()
|
||||
page = SettingsPage(store)
|
||||
page.show()
|
||||
QTest.qWait(10)
|
||||
self.fill(page)
|
||||
page.save()
|
||||
self.assertEqual(store.calls, [])
|
||||
self.assertTrue(page.device_token.hasFocus())
|
||||
self.assertIn("首次保存", page.feedback.text())
|
||||
|
||||
def test_first_save_rejects_65_character_token_without_silent_truncation(self) -> None:
|
||||
store = FakeStore()
|
||||
page = SettingsPage(store)
|
||||
page.show()
|
||||
QTest.qWait(10)
|
||||
self.fill(page)
|
||||
invalid_token = TOKEN + "b"
|
||||
page.device_token.setText(invalid_token)
|
||||
page.save()
|
||||
self.assertEqual(store.calls, [])
|
||||
self.assertEqual(page.device_token.text(), invalid_token)
|
||||
self.assertTrue(page.device_token.hasFocus())
|
||||
self.assertIn("64 位", page.feedback.text())
|
||||
|
||||
def test_visual_order_focuses_invalid_device_before_missing_token_or_adb(self) -> None:
|
||||
store = FakeStore()
|
||||
page = SettingsPage(store)
|
||||
page.show()
|
||||
QTest.qWait(10)
|
||||
page.device_id.setText("not-a-uuid")
|
||||
page.save()
|
||||
self.assertEqual(store.calls, [])
|
||||
self.assertTrue(page.device_id.hasFocus())
|
||||
self.assertIn("UUIDv4", page.feedback.text())
|
||||
|
||||
def test_existing_blank_token_passes_none_and_success_never_refills(self) -> None:
|
||||
store = FakeStore()
|
||||
page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True)
|
||||
page.save()
|
||||
self.assertEqual(len(store.calls), 1)
|
||||
self.assertIsNone(store.calls[0][1])
|
||||
self.assertEqual(page.device_token.text(), "")
|
||||
self.assertEqual(page.token_status.text(), "已保存")
|
||||
|
||||
def test_nonempty_token_replaces_once_then_is_cleared(self) -> None:
|
||||
store = FakeStore()
|
||||
page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True)
|
||||
page.device_token.setText(TOKEN)
|
||||
page.save()
|
||||
self.assertEqual(len(store.calls), 1)
|
||||
self.assertEqual(store.calls[0][1].value, TOKEN)
|
||||
self.assertEqual(page.device_token.text(), "")
|
||||
self.assertNotIn(TOKEN, page.feedback.text())
|
||||
self.assertNotIn(TOKEN, page.token_status.text())
|
||||
|
||||
def test_existing_token_replacement_rejects_65_characters_without_store_write(self) -> None:
|
||||
store = FakeStore()
|
||||
page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True)
|
||||
page.show()
|
||||
QTest.qWait(10)
|
||||
invalid_token = TOKEN + "b"
|
||||
page.device_token.setText(invalid_token)
|
||||
page.save()
|
||||
# 零写入即表示已保存的原 token 未被替换。
|
||||
self.assertEqual(store.calls, [])
|
||||
self.assertEqual(page.device_token.text(), invalid_token)
|
||||
self.assertTrue(page.device_token.hasFocus())
|
||||
self.assertEqual(page.token_status.text(), "已保存")
|
||||
|
||||
def test_failed_save_preserves_token_and_focuses_secret_field_without_echo(self) -> None:
|
||||
store = FakeStore(RuntimeError("must-not-appear-" + TOKEN))
|
||||
page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True)
|
||||
page.show()
|
||||
QTest.qWait(10)
|
||||
page.device_token.setText(TOKEN)
|
||||
page.save()
|
||||
self.assertEqual(page.device_token.text(), TOKEN)
|
||||
self.assertTrue(page.device_token.hasFocus())
|
||||
self.assertNotIn(TOKEN, page.feedback.text())
|
||||
|
||||
def test_pending_or_active_freezes_every_non_token_field_and_preserves_exact_settings(self) -> None:
|
||||
original = self.existing_settings()
|
||||
store = FakeStore()
|
||||
page = SettingsPage(
|
||||
store,
|
||||
settings=original,
|
||||
has_stored_device_token=True,
|
||||
identity_frozen=True,
|
||||
)
|
||||
for field in (
|
||||
page.device_id,
|
||||
page.adb_path,
|
||||
page.adb_serial,
|
||||
page.transport,
|
||||
page.poll_interval,
|
||||
page.failure_threshold,
|
||||
page.http_timeout,
|
||||
page.step_timeout,
|
||||
):
|
||||
self.assertFalse(field.isEnabled())
|
||||
self.assertTrue(page.device_token.isEnabled())
|
||||
page.device_token.setText(TOKEN)
|
||||
page.save()
|
||||
self.assertEqual(store.calls[0][0], original)
|
||||
|
||||
def test_page_has_no_connection_probe_command_and_explains_deferred_validation(self) -> None:
|
||||
page = SettingsPage(FakeStore())
|
||||
button_texts = [button.text() for button in page.findChildren(QPushButton)]
|
||||
self.assertEqual(button_texts, ["保存配置"])
|
||||
self.assertIn("首次真实领取", page.validation_hint.text())
|
||||
self.assertEqual(page.service_url.text(), "http://127.0.0.1:8080")
|
||||
self.assertTrue(page.service_url.isReadOnly())
|
||||
@@ -104,8 +104,10 @@ US-004 的试选后人工确认已从 MVP 删除。US-006 在 MVP 为:系统
|
||||
等于任务值。缺失、重复、禁用、前缀碰撞、读回不一致都停止,不选相近项。
|
||||
- **F-006 三道价格闸门**:价格只从规格面板和订单确认页读取,金额使用十进制字符串。
|
||||
1. 选中目标规格后读取当前 SKU 单价,计算 `单价 × 数量`,不得超过授权最高总价。
|
||||
2. 设置并复核数量后再次读取同一规格与单价;规格必须仍相同,单价必须与闸门一完全相等。
|
||||
3. 订单确认页核对规格、数量与应付总额;总额不得超过授权最高总价。
|
||||
2. 设置并复核数量后再次核对同一规格,并唯一读取规格面板的目标数量总额;总额不得超过授权
|
||||
最高总价。多件优惠可能非线性,不用闸门一金额乘数量猜总额,也不要求目标数量总额等于闸门一。
|
||||
3. 订单确认页核对规格、数量与应付总额;应付总额必须与闸门二面板总额严格相等,且再次不超过
|
||||
授权最高总价。
|
||||
任一道读不到、有歧义或不通过都停止并转人工,不从详情页、搜索页或按钮文字凑价格。
|
||||
- 同一设备会话连续完成上述步骤;不退出商品再等采购服务确认,也不把旧截图或旧页面读数当作
|
||||
当前提交依据。
|
||||
@@ -127,13 +129,13 @@ US-004 的试选后人工确认已从 MVP 删除。US-006 在 MVP 为:系统
|
||||
|
||||
### 结果、证据与异常
|
||||
|
||||
- **F-007**:按步骤回传实际规格、两次规格面板价格、数量读回、确认页总额、判定、时间和截图。
|
||||
- **F-007**:按步骤回传实际规格、闸门一单价、数量读回、闸门二面板总额、确认页总额、判定、时间和截图。
|
||||
截图是内部审计证据,不是提交前审批前置。
|
||||
- 内部系统允许上传规格面板与订单确认页的原始截图,截图中可保留页面已显示的地址和手机号;只允许
|
||||
已认证设备显式上传、已登录管理员查看。完整 XML 只留采购工具本机,不上传;地址/手机号不解析为
|
||||
业务字段或日志;外部支付页、支付凭据永不上传。
|
||||
- **F-011**:至少区分设备未连接、版本失配、商品打不开、面板打不开、规格不匹配、价格不可读、
|
||||
两次价格不一致、数量复核失败、金额超上限、确认页不一致、提交控件不唯一、围栏失败、页面识别
|
||||
规格漂移、数量复核失败、闸门二总额不可读或超上限、确认页与闸门二不一致、提交控件不唯一、围栏失败、页面识别
|
||||
失败、安全校验、外部支付交接和超时。
|
||||
- 运行时拼多多 App 版本与已取证版本不一致时停止领取真机任务,提示重新取证。
|
||||
- 订单明确创建后展示截图、商品、规格、数量和授权金额;人在拼多多付款后手工标记完成。
|
||||
|
||||
+27
-29
@@ -132,48 +132,46 @@ D:\Portable\adb\adb.exe devices -l
|
||||
证据不得提交 Git。T-102 已由人确认 goods_id `958756616606` 对应目标商品并完成截图/XML 隐私检查,
|
||||
设备、版本、证据路径与 SHA-256 已记录到任务执行记录。
|
||||
|
||||
### T-103 规格面板三状态只读取证(T-110 边界调整后待重新验证)
|
||||
### T-103 规格面板状态取证与受控选择
|
||||
|
||||
`client/scripts/capture_sku_panel_spike.py` 只采集人已在手机上准备好的规格面板截图和完整 XML。
|
||||
它不打开链接或规格面板,不识别面板,不点击、滑动、输入或选择规格,也不读取价格;接口只暴露
|
||||
`app_info`、`app_current` 和两个只读 JSON-RPC 方法。`panel-opened-target-preselected`、
|
||||
`alternate-all-dimensions-selected`、`target-selection-restored` 三种值写入 manifest 的字段名是
|
||||
`human_declared_state`,明确表示人工声明,不得将其当作自动识别结果。旧的“未选 / 单维度 / 全选”
|
||||
状态假设已被真机事实推翻,旧枚举会被采集器和脱敏器明确拒绝。运行拼多多版本必须精确为 `8.17.0`,
|
||||
前台 package 必须是拼多多,否则 fail closed;目标目录已存在、截图/XML 无效或超时均不得覆盖已有内容
|
||||
或发布半成品。
|
||||
`client/scripts/capture_sku_panel_spike.py` 是历史人工声明状态的只读取证工具:只采集人已准备好的截图和
|
||||
完整 XML,不打开链接或面板,不点击、滑动、输入、选择规格或读取价格。它保留的
|
||||
`panel-opened-target-preselected` 等枚举只描述旧证据目录,不能作为当前生产 classifier 的事实来源。
|
||||
运行拼多多版本必须精确为 `8.17.0`,前台 package 必须是拼多多;目标目录已存在、截图/XML 无效或
|
||||
超时均不得覆盖已有内容或发布半成品。
|
||||
|
||||
T-103 已证明当前衣服商品没有独立「规格/已选」入口。T-110 只批准拼多多 `8.17.0`、goods_id
|
||||
`937122477375` 上经真机确认的精确唯一 `快要抢光` 作为受控规格面板入口;“免拼购买 / 单独购买 /
|
||||
直接拼成”等其他文案不能凭人工经验复用,必须分别重新取证。项目所有者已确认面板刚打开时目标颜色
|
||||
“黑色CHA(纯棉)”和尺码“M(建议100-115)”均已自动选中;取证不强行取消选择,而是记录刚打开状态、
|
||||
人工把两个维度都改成非目标值、再恢复目标值三个真实状态。只读取证 CLI 本身仍不执行点击;三种状态
|
||||
分别使用一个全新原始证据目录:
|
||||
`937122477375` 上经真机确认的底部购买区第一行精确唯一 `快要抢光 + 金额` 作为受控规格面板入口;
|
||||
商品内容区同名小字必须零点击,“免拼购买 / 单独购买 / 直接拼成”等其他文案不能凭经验复用。
|
||||
|
||||
```powershell
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state panel-opened-target-preselected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-opened-target-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state alternate-all-dimensions-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-alternate-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state target-selection-restored --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
```
|
||||
2026-08-05 五组 live 证据纠正了旧的“面板打开即自动预选”假设:真实初态颜色与尺码均未选,选择
|
||||
目标颜色后尺码仍未选且不在视口;S 非目标态与恢复 M 目标态的 exact selected 切换已由人确认。
|
||||
当前实现必须分别匹配未选初态、仅目标颜色态、S 态和 M 态的完整 profile,不得跨状态拼装价格、摘要、
|
||||
容器或坐标。显示尺码只能由证据绑定、私有、一次且不重试的窄 reveal 能力完成;在取得 reveal 前后
|
||||
M 均未选的动作证据前,不得实现通用或猜测性滑动。
|
||||
|
||||
该动作证据使用独立 `client/scripts/capture_sku_reveal_spike.py`,不改造历史只读采集器,也不接入
|
||||
生产 Flow。CLI 不接收颜色、尺码、坐标或手势参数;它只在严格命中“目标颜色已选、尺码隐藏且未选”
|
||||
后执行一次证据绑定的固定手势,随后只读采集 before/after。M 意外选中、尺码仍隐藏、颜色/摘要漂移、
|
||||
RPC 结果不明且无法调和或提交区风险均不得发布,成功后保留面板等待人工核对。
|
||||
|
||||
PDD 规格面板不可避免显示收货区域和手机号。项目所有者确认 cmbuyer 是内部系统,正式流程允许把
|
||||
页面原始截图上传采购服务供已登录管理员查看,不做遮罩或裁剪。完整 XML 仍只留在
|
||||
`%LOCALAPPDATA%\cmbuyer\artifacts\...\raw`;采购工具运行时在内存中读取当前页面树,但只返回规格、
|
||||
选中态、价格和页面状态摘要,不把地址或手机号解析为业务字段,也不写入日志、Git 或 Vikunja。
|
||||
|
||||
T-103 已完成 `t103-privacy-v5` 的一次真实派生:人确认 XML 无实际地址/手机号、目标颜色
|
||||
“黑色CHA(纯棉)”与尺码“M(建议100-115)”保留;安全节点显示顶部当前价“快卖完 ¥12.88”与原价
|
||||
“¥29.88”,底部“提交订单 ¥12.88”明确属于 T-103 隔离能力的硬拒绝区。派生截图的整宽黑色隐私带
|
||||
遮住了顶部价格文字的一半,这只说明旧脱敏实验不适合展示,不否定已确认的规格面板价格事实。
|
||||
旧 `t103-privacy-v5` 派生物只保留为历史证据,不能覆盖上述 live 状态。闸门一只在目标颜色与 M
|
||||
均精确读回后,从已取证滚动态面板的只读当前价角色读取十进制 `12.88`;原价 `29.88`、未选初态价格、
|
||||
详情页数字和底部“提交订单 ¥12.88”都不得成为价格候选。
|
||||
|
||||
为加快 MVP,T-103 从此不再修改或调用截图遮罩器。现有 v5 派生 XML 只用于提取不含页面个人信息的
|
||||
最小 fixture;T-111 完成后的 `SkuSelectionFlow` 在本机实时页面树上按拼多多 8.17.0 与面板结构读取允许字段,完整
|
||||
页面树不落日志、不上传。T-204 直接接入原始截图上传,资产标记 `privacy_tier=INTERNAL_RAW`,只允许
|
||||
为加快 MVP,T-103 不再修改或调用截图遮罩器。最小 fixture 从上述本机 live 证据中只提取获准判据节点,
|
||||
不含地址、手机号或支付凭据;`SkuSelectionFlow` 在本机实时页面树上读取允许字段,完整页面树不落日志、
|
||||
不上传。T-204 直接接入原始截图上传,资产标记 `privacy_tier=INTERNAL_RAW`,只允许
|
||||
已认证设备写入、已登录管理员读取;不上传 XML,不允许外部支付页或支付凭据。
|
||||
|
||||
这个范围调整不授权 T-103 调整数量、进入确认页、点击“提交订单”或触碰支付控件。T-111 完成后,
|
||||
T-103 以 `SkuSelectionFlow` 继续:由人把两个维度改成明确的非目标值,再运行受控入口、精确选择、
|
||||
读价和安全退出脚本完成真机验收。生产业务最终为单趟,但能力仍按 T-103 / T-105 / T-107 分段取证。
|
||||
这个范围调整不授权 T-103 调整数量、进入确认页、点击“提交订单”或触碰支付控件。T-103 最终由人
|
||||
运行受控入口、目标颜色、一次 reveal、目标 M、读价和一次 Back 的完整真机验收;生产业务最终为单趟,
|
||||
但能力仍按 T-103 / T-105 / T-107 分段取证。
|
||||
|
||||
Windows 的标准入口是仓库根 `./init.ps1`。它要求 Go、两端目录及其哨兵文件存在;已有合规
|
||||
`client/.venv` 时,所有采购工具检查与 validator 都使用该解释器。只有 venv 不存在时,才从 `py -0p`
|
||||
|
||||
+44
-15
@@ -81,9 +81,9 @@ MVP 只做任务自带商品链接的 A 路径。创建任务与开始采购分
|
||||
│ 3. 按维度精确选择并读回颜色、尺码
|
||||
│ 4. 【闸门一】读 SKU 单价;单价×数量不得超过最高总价
|
||||
│ 5. 设置数量并精确读回
|
||||
│ 6. 【闸门二】重读规格与单价;规格不变且价格等于闸门一
|
||||
│ 6. 【闸门二】重读规格、数量与面板总额;总额不超最高总价
|
||||
│ 7. 进入订单确认页
|
||||
│ 8. 【闸门三】规格/数量一致,应付总额不超最高总价
|
||||
│ 8. 【闸门三】规格/数量一致,应付总额等于闸门二且不超最高总价
|
||||
│ 9. 上传验证摘要并申请服务端提交围栏
|
||||
│ 10. 仅在 click_permitted=true 且提交控件唯一时点击一次
|
||||
│ 11. 回传观察结果;不确定时只调和,不重试
|
||||
@@ -98,28 +98,57 @@ RECONCILIATION_REQUIRED ──人工核查同一提交──> WAITING_PAYMENT /
|
||||
|
||||
### 受控规格面板入口
|
||||
|
||||
T-103 真机证据表明:拼多多 `8.17.0`、goods_id `937122477375` 通过精确文本“快要抢光”打开
|
||||
规格面板。T-110 已批准把该**特定证据、版本和页面状态**绑定的点击定义为受控导航。
|
||||
T-103 最新真机证据表明:拼多多 `8.17.0`、goods_id `937122477375`、1080×2376 的规格面板
|
||||
入口是屏幕底部购买区第一行 `快要抢光 + 金额`,不是商品内容区的同名促销小字。T-110 已批准把
|
||||
该**特定证据、版本和页面状态**绑定的点击定义为受控导航。
|
||||
|
||||
- 只能精确唯一匹配;缺失、重复、版本失配或打开后面板不唯一时零后续点击。
|
||||
- “免拼购买 / 单独购买 / 直接拼成”等其他文案不能用包含、前缀、同义或坐标兜底。
|
||||
- 只能精确唯一匹配底部非点击文本叶节点、其直接可点击 PDD 父容器及证据绑定结构,并且只点击
|
||||
第一行文本叶节点中心;缺失、重复、版本失配、结构漂移或打开后面板不唯一时零后续点击。
|
||||
- 商品内容区同名促销小字明确禁止作为入口。底部按钮中的金额只参与结构识别,不得解析、返回或
|
||||
充当价格来源;价格仍只能从规格面板和订单确认页读取。
|
||||
- `免拼购买` 只允许作为已取证、不可点击的精确兄弟节点验证底部结构,不能作为选择器、目标或兜底;
|
||||
它变为可点击、位置漂移或出现在其他结构时必须拒绝。“单独购买 / 直接拼成”等其他文案同样不能
|
||||
用包含、前缀、同义或坐标兜底。
|
||||
- 受控入口只负责进入已取证面板,不等于支付授权,也不能暴露通用任意点击能力。
|
||||
- T-103 的隔离验证 capability 只包含开商品、开面板、选规格、读价和安全退出;数量、确认页、
|
||||
提交和支付仍由后续真机任务分别取证后才能接入生产单趟执行器。
|
||||
|
||||
2026-08-05 的五组 8.17.0 真机证据进一步证明,面板刚打开时颜色与尺码均未选中,尺码选项尚未
|
||||
进入视口;选择目标颜色后,摘要只剩“请选择:尺码”,但尺码仍不可见。实现必须把未选初态、
|
||||
仅颜色已选态、非目标尺码态和目标尺码态作为各自完整的证据 profile 匹配,禁止把不同状态的价格、
|
||||
摘要、容器或坐标拼成一个宽松判据。
|
||||
|
||||
- 显示尺码只允许命名的私有 `reveal_size_options()` 窄能力:仅从已证明的“目标颜色已选、尺码未选
|
||||
且隐藏”状态执行,绑定证据确认的嵌套滚动容器与列间安全通道,最多一次且不重试;不得公开通用
|
||||
`swipe`、`scroll`、任意坐标或把底部提交区纳入动作范围。
|
||||
- 生产 reveal 落地前,必须补齐“动作前后目标尺码均未选、尺码由隐藏变为可见、提交区未触发”的
|
||||
同版本真机动作证据。现有“滚动后 M 已选中”快照不能证明滚动没有误点规格,不得据此猜测手势。
|
||||
- 这组证据只能由独立 spike 在严格前置成立后执行一次固定、无参数手势采集;生产 Flow 不得调用该
|
||||
spike。RPC 超时或响应不明时只允许只读调和,不得重试;候选后置必须再由人核对后才能成为生产判据。
|
||||
- 尺码 action 的真实节点类型、精确文案和 selected 读回必须随 profile 一起验证;当前证据中的 S/M
|
||||
是可点击 `TextView`。只有目标颜色与目标尺码双重读回一致后才能读取闸门一价格。
|
||||
- 一次 Back 在调用前即封存且不得重试;仅“面板消失”不能作为生产安全退出的充分条件。同商品详情页
|
||||
的严格退出判据由 T-104 取得独立真机证据后开放。
|
||||
|
||||
### 三道价格闸门
|
||||
|
||||
| 闸门 | 当前页面 | 判据 | 拒绝条件 |
|
||||
| --- | --- | --- | --- |
|
||||
| 一 | 规格面板,选中目标规格后 | 单价唯一可读;`单价 × 授权数量 <= 最高总价` | 不可读、有歧义或超上限 |
|
||||
| 二 | 规格面板,数量读回后 | 颜色、尺码仍正确;重读单价与闸门一完全相等 | 规格漂移或价格变化 |
|
||||
| 三 | 订单确认页 | 规格、数量正确;应付总额唯一可读且不超最高总价 | 任一不一致、不可读或超上限 |
|
||||
| 二 | 规格面板,数量读回后 | 颜色、尺码仍正确;目标数量面板总额唯一可读且不超最高总价 | 规格/数量漂移、总额不可读/有歧义或超上限 |
|
||||
| 三 | 订单确认页 | 规格、数量正确;应付总额唯一可读、严格等于闸门二且不超最高总价 | 任一不一致、不可读、有歧义或超上限 |
|
||||
|
||||
金额一律使用十进制字符串和十进制定点运算,不用浮点数。价格只从规格面板和订单确认页读取;
|
||||
详情正文、搜索卡片、底部购买/提交按钮的数字不作为价格来源。
|
||||
|
||||
闸门一与闸门二发生在同一设备会话中。它们用于发现选择或设置数量造成的页面变化,不需要管理员
|
||||
在中间确认。旧截图、缓存值和发布前 dry-run 都不能替代这次实时读取。
|
||||
2026-08-06 的拼多多 `8.17.0` 真机证据证明同一规格的多件优惠是非线性的:数量 1 时规格面板顶部
|
||||
当前金额为 `12.88`,数量 2 时变为 `32.76`,而不是 `12.88 × 2`。因此闸门一仍是设置数量前的单价
|
||||
预检,闸门二必须读取设置后的**面板总额** `gate2_panel_total_price` 并直接比较最高总价;不得把
|
||||
Gate1 乘数量、不得要求 Gate2 等于 Gate1,也不得把底部“提交订单 ¥...”金额作为价格候选或兜底。
|
||||
Gate3 再要求确认页应付总额与 Gate2 面板总额严格相等,这比只检查上限多一道跨页防漂移约束。
|
||||
|
||||
闸门一与闸门二发生在同一设备会话中,不需要管理员在中间确认。旧截图、缓存值和发布前 dry-run
|
||||
都不能替代这次实时读取。
|
||||
|
||||
### B 路径(V2)
|
||||
|
||||
@@ -153,8 +182,8 @@ T-103 真机证据表明:拼多多 `8.17.0`、goods_id `937122477375` 通过
|
||||
|
||||
1. **一次性授权有效且服务端提交围栏已建立**:围栏把授权、任务、领取和本次验证摘要原子绑定到
|
||||
唯一 `order_submission`;明确响应包含 `click_permitted=true`。
|
||||
2. **闸门二通过**:目标规格未漂移,第二次规格面板单价等于第一次。
|
||||
3. **闸门三通过**:确认页规格、数量正确,应付总额不超过授权最高总价。
|
||||
2. **闸门二通过**:目标规格和数量未漂移,目标数量面板总额唯一可读且不超过授权最高总价。
|
||||
3. **闸门三通过**:确认页规格、数量正确,应付总额严格等于闸门二面板总额,且不超过授权最高总价。
|
||||
4. **提交控件唯一**:文本精确等于“提交订单”,可点击祖先唯一。
|
||||
|
||||
围栏请求超时、断网、冲突或响应不明时不得点击。围栏建立后:
|
||||
@@ -223,7 +252,7 @@ CREATE TABLE purchase_attempts (
|
||||
claim_generation INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
gate1_unit_price TEXT,
|
||||
gate2_unit_price TEXT,
|
||||
gate2_panel_total_price TEXT,
|
||||
quantity_read INTEGER,
|
||||
confirm_amount TEXT,
|
||||
failure_code TEXT,
|
||||
@@ -273,7 +302,7 @@ CREATE TABLE order_submissions (
|
||||
attempt_id TEXT NOT NULL REFERENCES purchase_attempts(id),
|
||||
status TEXT NOT NULL, -- FENCED | SUBMITTED | RECONCILIATION_REQUIRED | MANUAL_RESOLVED
|
||||
gate1_unit_price TEXT NOT NULL,
|
||||
gate2_unit_price TEXT NOT NULL,
|
||||
gate2_panel_total_price TEXT NOT NULL,
|
||||
quantity_read INTEGER NOT NULL,
|
||||
confirm_amount TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
@@ -435,7 +464,7 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
|
||||
| 页面结构随版本变化 | 旧选择器误点新页面 | 每条判据先真机取证,记录 App 版本、截图、XML、goods_id |
|
||||
| 购买语义入口才打开面板 | 能力范围易扩散 | 只批准证据绑定的精确唯一入口;每种文案单独取证 |
|
||||
| 当前价与原价/按钮价混杂 | 读错价格 | 限定已取证结构和语义;价格只在面板/确认页读,歧义即停 |
|
||||
| 单趟页面状态变化 | 数量或促销导致价格变化 | 同一趟两次读规格面板价,再以确认页总额兜底 |
|
||||
| 单趟页面状态变化 | 数量或促销导致金额非线性变化 | 同一趟读 Gate1 单价与 Gate2 面板总额,再要求确认页总额与 Gate2 严格相等 |
|
||||
| WiFi ADB / 双通道 | 断连或操作错设备 | serial 必填;USB/WiFi 同设备或身份不明时 fail closed |
|
||||
| 不可逆动作超时 | 可能已创建订单 | 服务端围栏 + 点击一次 + 只调和,不重试 |
|
||||
| 双端契约漂移 | 静默不兼容 | [api.md](api.md) 唯一权威;契约改动跑完整双端门禁 |
|
||||
|
||||
@@ -45,8 +45,9 @@
|
||||
- 价格**只在规格面板(闸门一 / 二)和订单确认页(闸门三)读**。
|
||||
- **不从商品详情页正文、搜索结果卡片或任何其他位置读价格。**
|
||||
- 读不到就转人工,**不用别处的数字凑合**。
|
||||
- 同一趟设置并复核数量后,闸门二重读的规格必须不变、单价必须与闸门一完全一致;授权锁定的是
|
||||
最高总价而不是旧观察价。价格变化即停。
|
||||
- 同一趟设置并复核数量后,闸门二重读的规格必须不变,并唯一读取目标数量的规格面板总额,直接
|
||||
验证不超授权最高总价;后续闸门三应付总额必须与该面板总额严格相等并再次不超上限。2026-08-06
|
||||
真机已证明多件优惠非线性,不得用闸门一金额乘数量猜 Gate2 总额,也不得要求 Gate2 等于 Gate1。
|
||||
- 图片搜索(V2)的唯一产出是 `goods_id`,同样不读价。
|
||||
|
||||
### 1.4 安全与隐私
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ Phase 是里程碑分组,不是整段串行栅栏。推荐波次:
|
||||
| T-102 | 验证按链接打开商品详情页 | T-101 | canonical goods 链接进入对应商品;前台包与 goods_id 有证据 |
|
||||
| T-103 | 验证受控规格面板、精确选择与 SKU 单价 | T-102, T-110, T-111 | 精确唯一入口、维度内等值选择/读回、防前缀碰撞、当前价唯一读取;隔离 capability 不含数量/确认/提交 |
|
||||
| T-104 | 验证规格选择能力安全退出 | T-103 | 完成选择/读价/截图后可关闭面板或退出;不进入确认页、不提交 |
|
||||
| T-105 | 验证数量设置、读回与闸门二 | T-104 | 先只读取证并由人确认,再设置数量、精确读回并复核规格/单价;不一致即停 |
|
||||
| T-105 | 验证数量设置、读回与闸门二 | T-104 | 先只读取证并由人确认,再设置数量、精确读回并复核规格/目标数量面板总额;规格或数量不一致、总额不可读/超上限即停 |
|
||||
| T-106 | dry-run:确认页与闸门三取证 | T-105 | 只读采集导航前、确认页、最终控件及一次人工 Back 状态;agent 不执行页面动作 |
|
||||
| T-107 | 固化确认页与提交控件安全判据 | T-106 | 只点已取证导航控件一次;最终控件只读观察;一次 Back;静态不可达提交 |
|
||||
| T-110 | 批准证据绑定的受控规格入口 | T-103 no-go 决策 | 仅批准拼多多 8.17.0 已取证的精确唯一“快要抢光”,不泛化 |
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
- 启动前同时校验采购服务、ADB 设备和已取证拼多多版本;任一不满足则不启动。
|
||||
- 顶部始终显示会话状态;当前任务、图片、滚动日志和倒序采购记录实时更新。
|
||||
- 精确规格、两次单价、数量读回、确认页总额或提交控件任一不合格时停止该任务。
|
||||
- 精确规格、闸门一单价、数量读回、闸门二面板总额、确认页同额校验或提交控件任一不合格时停止该任务。
|
||||
- 围栏明确成功后只点一次“提交订单”;系统绝不点击付款。
|
||||
- 连续轮询失败达到阈值自动停止;停止轮询不取消正在执行或已围栏的任务。
|
||||
|
||||
|
||||
@@ -57,7 +57,8 @@
|
||||
|
||||
### IX-006 待付款核对与标记完成
|
||||
|
||||
- 页面显示商品、目标与实际规格、数量、授权上限、两次单价、确认页总额和三闸门内部证据;MVP
|
||||
- 页面显示商品、目标与实际规格、数量、授权上限、闸门一单价、闸门二面板总额、确认页同额校验和
|
||||
三闸门内部证据;MVP
|
||||
没有提交后结果截图类型,不把确认页图片冒充订单结果图。
|
||||
- 首屏警示:“订单已创建,系统尚未付款。请在拼多多人工核对并付款。”
|
||||
- “标记为已付款并完成”只写系统状态,不打开支付页面、不触发手机操作。
|
||||
|
||||
+10
-5
@@ -326,17 +326,21 @@ claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限
|
||||
"selected_color": "黑色CHA(纯棉)",
|
||||
"selected_size": "M(建议100-115)",
|
||||
"gate1_unit_price": "12.88",
|
||||
"gate2_unit_price": "12.88",
|
||||
"gate2_panel_total_price": "32.76",
|
||||
"quantity_read": 2,
|
||||
"confirm_page_amount": "25.76",
|
||||
"confirm_page_amount": "32.76",
|
||||
"submit_control_match_count": 1
|
||||
}
|
||||
```
|
||||
|
||||
服务端在一个事务中校验:任务/版本/claim/attempt 一致;授权有效未消费且字段等于任务快照;
|
||||
规格与授权相等;数量相等;两个单价相等;计算金额及确认页金额均不超过 `total_price_cap`;提交控件
|
||||
计数为一;此前不存在该授权或 attempt 的 submission。随后创建唯一 `order_submission`,授权转
|
||||
`FENCED`,任务保持不可重领。
|
||||
规格与授权相等;数量相等;闸门一单价预检通过;闸门二目标数量面板总额不超过 `total_price_cap`;
|
||||
确认页金额严格等于闸门二面板总额且再次不超过上限;提交控件计数为一;此前不存在该授权或 attempt
|
||||
的 submission。随后创建唯一 `order_submission`,授权转 `FENCED`,任务保持不可重领。
|
||||
|
||||
`gate2_panel_total_price` 是目标数量下规格面板顶部证据角色的总额,不是单价。拼多多多件优惠可能
|
||||
非线性,禁止用 `gate1_unit_price × quantity_read` 推导它,也禁止继续用旧字段名
|
||||
`gate2_unit_price` 承载总额。底部“提交订单 ¥...”文字不属于价格来源。
|
||||
|
||||
首次明确成功响应:
|
||||
|
||||
@@ -443,6 +447,7 @@ events/fail/fence/result 或完整 `ResultSink`。T-304/T-306 必须通过 `Dura
|
||||
| `select_sku_options()` | 维度 → 精确值 | 选中态摘要 | 维度内唯一匹配并读回 |
|
||||
| `read_sku_unit_price()` | 已确认规格面板 | 十进制单价 | 排除原价、按钮价和歧义候选 |
|
||||
| `set_quantity_and_readback()` | 授权数量 | 实际数量 | 精确读回,否则停 |
|
||||
| `read_sku_panel_total_price()` | 已读回目标数量 | 十进制面板总额 | 只读证据绑定的顶部当前金额;排除底部提交按钮金额和歧义候选 |
|
||||
| `go_to_order_confirm()` | 已通过闸门二 | 确认页摘要 | 后续真机任务取证后才实现 |
|
||||
| `submit_order_once()` | 不可伪造的首次 `SubmissionPermit` | 观察结果 | 许可、闸门、唯一控件全校验;点前持久化;绝不重试 |
|
||||
|
||||
|
||||
+105
-33
@@ -11,8 +11,8 @@
|
||||
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-08-04
|
||||
- 阶段:**Phase 1 · T-103 规格选择/读价与 Phase 2 安全服务端任务并行**
|
||||
- 日期:2026-08-06
|
||||
- 阶段:**Phase 1 · T-106 确认页只读取证与 Phase 2 安全服务端任务并行**
|
||||
- MVP 形态:手工填链接建单 → 批量“开始采购”并签发一次性授权 → 定时领取 → 同一趟精确
|
||||
选规格 / 三道价格闸门 / 提交围栏 / 点击一次 → 待付款。管理员点击“开始采购”即授权桌面端
|
||||
创建待付款订单;不再有试选后确认,系统绝不自动付款。
|
||||
@@ -22,13 +22,15 @@
|
||||
- 生产代码:`admin/` 已有登录/会话、手工 DRAFT 建单、批量开始采购与一次性授权、任务详情、内部
|
||||
原始截图、设备身份,以及已授权任务的原子领取/幂等重放/租约续期;
|
||||
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
|
||||
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板状态的只读取证 CLI,
|
||||
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板与数量两态的只读取证 CLI,
|
||||
绑定 PKG110 / Android 16 / 拼多多 8.17.0 的证据工具,以及受控规格入口、精确规格选择、SKU 当前价
|
||||
读取、原始截图和一次安全退出能力;并已具备严格 localhost claim/renew/evidence HTTP、Windows DPAPI
|
||||
凭据保护、SQLite append-only 恢复状态图、Global named mutex 与 durable gateway。真机能力尚待最终人工
|
||||
验收,数量/确认页/提交仍未开放。
|
||||
凭据保护、SQLite append-only 恢复状态图、Global named mutex 与 durable gateway。原生桌面主界面、
|
||||
显式本地配置、停止/重启会话边界和安全轮询协调器已实现;未注入单趟执行 consumer 时保持零领取。
|
||||
已取证的固定尺码 reveal、精确 M 选择、规格面板单价 `12.88` 与一次 Back 已进入生产 Flow 并完成
|
||||
真机人工验收;T-104 已把同商品详情页安全退出收紧为版本绑定、连续两帧稳定判据,数量/确认页/提交仍未开放。
|
||||
- 测试:采购服务已覆盖登录、建单、授权、详情/证据、设备身份、迁移、原子 claim/renew 与竞态;
|
||||
采购工具 192 项离线单元测试(全部 mock,不连接真机)。
|
||||
采购工具 346 项离线单元测试(全部 mock,不连接真机)。
|
||||
- 数据:受保护迁移已落到 `00005_task_claims.sql`。旧两趟模型已移除;开始采购会锁定任务快照并签发
|
||||
一次性授权,领取会创建唯一 attempt/claim、HMAC claim token 与有界租约。T-211 已统一 title、SKU、
|
||||
goods_id 与金额的双端 wire 上限,并证明最坏合法 claim 响应小于 32 KiB。仓库不含业务实例数据。
|
||||
@@ -39,13 +41,19 @@
|
||||
editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。
|
||||
- 当前设备门禁:人工已确认拼多多 8.17.0、goods_id `937122477375` 的衣服商品只能通过“快要抢光”
|
||||
打开规格面板;T-110 已获项目所有者批准,只把该证据/版本绑定的精确唯一入口作为受控导航。
|
||||
T-103 的隔离 capability 仍不得包含数量、确认页、提交订单、付款或通用点击;这些动作由后续任务
|
||||
分别取证后才接入生产单趟流程。T-103 的 v5 派生 XML 已由人确认无实际地址/
|
||||
手机号,并保留目标预选规格、顶部当前价“快卖完 ¥12.88”和原价“¥29.88”;底部“提交订单 ¥12.88”
|
||||
继续属于硬拒绝区。派生截图虽然把顶部价格遮住一半,但项目已停止遮罩器开发,视觉完整性不再阻塞
|
||||
规格选择与读价;T-204 已允许认证设备上传内部原始截图供管理员查看。入口父链证据已由人确认并
|
||||
收紧为精确唯一“快要抢光”子节点及证据绑定可点击祖先,“免拼购买”仍为硬拒绝。离线实现和 112 项
|
||||
测试已通过;T-103 只差人把手机置于 PDD 安全起点后完成精确规格恢复、读取 `12.88` 和一次安全退出。
|
||||
T-103 的隔离 capability 不包含数量、确认页、提交订单、付款或通用点击;这些动作由后续任务
|
||||
分别取证后才接入生产单趟流程。T-103 旧 v5 派生 XML 中的“打开即目标预选”已被 2026-08-05
|
||||
五组最新 live 证据纠正:真实初态为颜色与尺码均未选,目标颜色选中后尺码仍未选且不在视口;
|
||||
S 非目标值与恢复目标 M 的前后态已由人确认。底部“提交订单 ¥12.88”继续属于硬拒绝区。项目已停止
|
||||
遮罩器开发,T-204 已允许认证设备上传内部原始截图供管理员查看。入口父链证据已由人确认并
|
||||
最新失败证据已纠正入口绑定:此前实现误点商品内容区同名促销小字;真实入口是底部购买区第一行
|
||||
`快要抢光 + 金额` 的精确文本叶节点中心。第二行“免拼购买”只能作为不可点击的证据绑定兄弟节点
|
||||
校验结构,绝不作为选择器或目标;按钮金额不作为价格来源。提交 `2f1380d` 已把入口改为只点击
|
||||
第一行中心 `(865,2218)`,旧内容区小字为零点击反例并保留一次点击无重试。T-103 已完成固定一次
|
||||
reveal 的原子证据、人审和生产化:同一 Flow 只允许 current-only 初态依次执行目标颜色、reveal、
|
||||
exact M,随后复用既有 dual 目标态读取 `12.88`;入口、颜色、reveal、M、Back 各一次的生产真机
|
||||
manifest 与 Back 前后截图已由项目所有者完成三项人工确认。T-104 又用独立 post-exit 真机证据,
|
||||
把“Back 后仍为同一商品”收紧为完整标题正锚、底部入口、连续两帧与版本绑定的自动判据。
|
||||
完整事件链必须等待 T-107 与 T-210:先让 Gate2/Gate3 证据进入真实 schema,再实现 T-205 事件,
|
||||
不用 mock 或无证据成功事件抢跑。
|
||||
|
||||
@@ -54,11 +62,11 @@
|
||||
| 路径 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
|
||||
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-211、T-301~T-308、T-400~T-405) | T-303、T-211 已完成;T-103 等最终真机验收;双端围栏、真实提交、调和、打包与只读验收均已落成任务 |
|
||||
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-211、T-301~T-308、T-400~T-405) | T-103~T-105、T-303、T-211 已完成;T-304 代码完成后保持 DOING 等人工 UI 复核;T-106 已领取并完成只读取证代码,等待四态真机人工验收;双端围栏、真实提交、调和、打包与只读验收均已落成任务 |
|
||||
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
||||
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
||||
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含建单/授权/详情/证据/设备身份与原子 claim/renew;不执行真机动作 |
|
||||
| `client/` | 已初始化 | Python 3.11+、PySide6/uiautomator2、严格 HTTP/DPAPI/SQLite 恢复底座及受控规格选择/读价/安全退出;数量、确认页和提交未开放 |
|
||||
| `client/` | 已初始化 | Python 3.11+、PySide6/uiautomator2、固定双 Tab 主界面、安全轮询、严格 HTTP/DPAPI/SQLite 恢复底座及受控规格选择/读价/数量 Gate2/安全退出;T-106 确认页四态仅开放纯只读取证,确认页生产导航和提交仍未开放 |
|
||||
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
|
||||
|
||||
## 任务状态
|
||||
@@ -76,10 +84,21 @@
|
||||
`DRAFT`,未实现授权、设备领取或采购执行。
|
||||
- 已完成 T-110、T-111、T-203、T-204、T-301 与 T-302。服务端已能授权、认证设备、原子领取、
|
||||
稳定重放和续租;claim token 不是提交许可,尚无事件、提交围栏或结果 API。
|
||||
- T-103 离线 `SkuSelectionFlow` 已实现并通过独立审计,仍为 `DOING` 等人工真机验收;T-104~T-107
|
||||
按数量/确认页风险逐段开放。服务端后续顺序已收紧为 T-210 → T-205 → T-206 → T-207 → T-208。
|
||||
- T-303 已完成并通过三重审查、189 项客户端测试和合并后根门禁;客户端下一步按依赖推进
|
||||
T-304/T-306/T-307/T-308,再与 T-305 汇合到
|
||||
- T-103 已完成受控入口、精确颜色、固定 reveal、exact M、规格面板 `12.88` 读价、一次 Back 和最终
|
||||
人工真机验收;T-104 已完成同商品安全退出,T-105 已完成数量/Gate2,T-106~T-107 继续按确认页风险逐段开放。真机运行要求
|
||||
同一物理手机只保留一个 ADB 通道;CLI 已增加不回显页面
|
||||
正文的固定白名单失败阶段码;`sku_entry` 动态页面误判已按最新真机证据修复,覆盖点击中心的浮窗
|
||||
会保持零点击,入口失败也已细分为前置、发现、点击和面板后置验证四个固定子阶段。服务端后续顺序已收紧为
|
||||
T-210 → T-205 → T-206 → T-207 → T-208。
|
||||
- T-105 已完成。阶段一数量 1/2 两态 screenshot/XML/App/设备摘要已完成四项人工确认。真机证明同一规格
|
||||
数量 1 面板金额为 `12.88`、数量 2 为 `32.76`,多件优惠非线性;Gate2 已改为唯一读取目标数量
|
||||
面板总额并直接比较最高总价,后续 Gate3 必须与其严格相等。阶段二的一次加号、精确读回、Gate2
|
||||
原始截图和 T-104 一次安全退出已实现并通过 330 项离线测试及完整 `init.ps1`。最终生产运行精确
|
||||
完成一次加号与一次 Back,项目所有者确认数量 2、目标规格、`12.88/32.76`、同商品安全退出,且未进入
|
||||
确认/提交/支付页、未创建订单。底部“提交订单”金额继续排除且禁止点击;T-106 依赖已解除。
|
||||
- T-303 已完成并通过三重审查;T-304 的轮询会话、配置页和固定双 Tab 主界面已通过两轮独立代码审计,
|
||||
当前 255 项客户端测试与根门禁通过,继续保持 `DOING` 等原生 Windows DPI、主题、高对比度、
|
||||
Narrator 和纯键盘人工验收。客户端后续按依赖推进 T-306/T-307/T-308,再与 T-305 汇合到
|
||||
T-400 离线一次性提交安全编排。围栏后管理链为 T-403 → T-402;T-401 必须在新的真实单趟重新
|
||||
通过三闸门,首次点击后固定进入 `UNKNOWN`/人工调和,T-405 打包后由 T-404 只读验收。
|
||||
- T-211 已完成 claim wire 字段上限闭合:创建、授权、candidate、持久化快照、重放、HTTP 输出和
|
||||
@@ -163,16 +182,70 @@ D:\Portable\adb\adb.exe devices -l
|
||||
不得提交 Git。T-102 已由人确认 goods_id `958756616606` 的目标商品及截图/XML 隐私,并完成验收。
|
||||
|
||||
T-103 已确认当前衣服商品只能从精确文案“快要抢光”进入规格面板;T-110 只批准该证据/版本绑定入口,
|
||||
不授权“免拼购买 / 单独购买 / 直接拼成”等其他文案。面板刚打开时目标颜色“黑色CHA(纯棉)”与尺码
|
||||
“M(建议100-115)”已经自动选中。v5 派生 XML 与人工观察已证明顶部当前价为“快卖完 ¥12.88”、
|
||||
并列原价为“¥29.88”;促销前缀会变化,正式读价不能把该文案作为主判据,必须以面板结构、价格角色、
|
||||
严格金额格式和唯一性组合判断,冲突时返回 unreadable。
|
||||
不授权“免拼购买 / 单独购买 / 直接拼成”等其他文案。2026-08-05 五组 live 证据证明面板刚打开时两维
|
||||
均未选,选择目标颜色后尺码仍未选且在视口外;S 与恢复 M 的 exact selected 切换已经人工确认。
|
||||
初态与滚动态的价格文案、坐标和容器会整体变化,必须按完整 profile 判断,禁止跨 profile 拼装。
|
||||
正式闸门一只在目标颜色和 M 均精确选中后,从已取证滚动态的只读价格角色读取 `12.88`;原价
|
||||
`29.88`、详情页数字和底部提交按钮价均不得成为候选,冲突时返回 unreadable。
|
||||
|
||||
本轮不再运行或修改截图遮罩器。T-103 先用本机证据验证真机能力;T-204 再把原始规格页截图上传采购
|
||||
服务,允许页面中已显示的地址和手机号,完整 XML 不上传。代码从已确认的派生 XML 提取最小 fixture,
|
||||
并在真机运行时只返回获准摘要。T-111 完成后,下一步实现 `SkuSelectionFlow` 的受控入口、
|
||||
按维度精确选择、选中态读回、SKU 单价读取与安全退出;完成离线主审后,由人先把两个维度改成明确
|
||||
记录的非目标值,再执行真机脚本确认恢复目标规格与 `12.88` 单价。生产原始截图展示/上传由 T-204 处理。
|
||||
本轮不再运行或修改截图遮罩器。T-103 已从五组本机 live 证据提取不含个人信息的最小 fixture,
|
||||
并在真机运行时只返回获准摘要;原始截图展示/上传由 T-204 处理,完整 XML 不上传。面板四态
|
||||
classifier、颜色与 S→M 精确选择、滚动态价格判据和独立一次性 reveal 证据脚本已经落地;生产 Flow
|
||||
中的受控 reveal 仍必须等待动作前后 M 均未选的真机证据和人工确认,最终完整链路也必须由人验收。
|
||||
|
||||
T-105 阶段一只读取证命令如下。脚本不打开页面,也不点击数量、返回、确认、提交或付款控件;必须由人
|
||||
先把目标商品规格面板准备为“黑色CHA(纯棉) + M(建议100-115) + 数量 1”,完成初态取证后再由人只点
|
||||
一次数量 `+` 准备数量 2,随后运行目标态取证:
|
||||
|
||||
```powershell
|
||||
# 仓库根目录;当前只保留已人工确认的 WiFi ADB 通道
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_quantity_gate2_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state initial --declared-quantity 1 --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-105\quantity-initial-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_quantity_gate2_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state target --declared-quantity 2 --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-105\quantity-target-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
```
|
||||
|
||||
两次成功目录只保存在本机。人工需核对截图/XML 确实对应数量 1/2、目标颜色和尺码、规格面板顶部金额,
|
||||
并确认过程中未进入确认/提交/支付页且未创建订单;确认前 T-105 保持 `DOING`。
|
||||
|
||||
2026-08-06 两态已经完成上述人工确认。初态目录为
|
||||
`%LOCALAPPDATA%\cmbuyer\artifacts\T-105\quantity-initial-937122477375-20260806-v3`,目标态目录为
|
||||
`%LOCALAPPDATA%\cmbuyer\artifacts\T-105\quantity-target-937122477375-20260806`。数量节点精确读回
|
||||
`1 → 2`,唯一加号的 `content-desc` 为“增加数量”,目标颜色和 M 均未漂移;顶部当前金额
|
||||
`12.88 → 32.76`。旧 `gate2_unit_price` 语义已废止,T-210/T-205 消费前必须把 API/存储迁移为
|
||||
`gate2_panel_total_price`,迁移未完成不得建立提交围栏。
|
||||
|
||||
T-105 阶段二生产验收已在人工确认的同一手机、拼多多 `8.17.0`、目标颜色/尺码和数量 1 面板完成。
|
||||
脚本自己只点一次证据绑定的数量加号到 2,读回 `32.76`、保存 Gate2 原图并执行一次安全 Back;不打开
|
||||
确认页,不点击底部“提交订单”,不创建订单。输出目录必须不存在:
|
||||
|
||||
```powershell
|
||||
.\client\.venv\Scripts\python.exe client\scripts\run_t105_quantity_gate2.py --serial 192.168.0.173:5555 --goods-id 937122477375 --color "黑色CHA(纯棉)" --size "M(建议100-115)" --target-quantity 2 --gate1-unit-price 12.88 --gate1-screenshot "$env:LOCALAPPDATA\cmbuyer\artifacts\T-105\quantity-initial-937122477375-20260806-v3\screenshot.png" --gate1-captured-at "2026-08-06T00:53:00.023935+00:00" --max-total-price 40.00 --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-105\quantity-gate2-live-937122477375-20260806" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
```
|
||||
|
||||
成功证据目录为 `%LOCALAPPDATA%\cmbuyer\artifacts\T-105\quantity-gate2-live-937122477375-20260806`;
|
||||
项目所有者已确认数量精确为 2、颜色尺码未变、Gate1/Gate2 分别为 `12.88/32.76`、Gate2 截图正确、
|
||||
一次 Back 回到同一商品详情,且未进入确认/提交/支付页、未创建订单。该命令只作已验运行记录,不再重跑。
|
||||
|
||||
T-106 已提供四态纯只读取证命令。每次运行都会重新核对设备、拼多多 `8.17.0`、前台包和
|
||||
1080×2376 屏幕,只调用截图与 `compressed=false` XML;不会打开页面、点击、返回、滑动、输入或识别
|
||||
确认页业务字段。四个输出目录必须均不存在,完整 XML 只留本机:
|
||||
|
||||
```powershell
|
||||
# 1. 人工准备:Gate2 已通过、尚未进入确认页;运行后再由人记录实际进入确认页的精确控件文本/容器/匹配数
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_order_confirm_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state gate2-navigation-source --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-106\gate2-navigation-source-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
|
||||
# 2. 人工只点击已核对的确认页入口并停在 Gate3 页面;绝不点击最终“提交订单”
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_order_confirm_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state confirm-gate3 --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-106\confirm-gate3-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
|
||||
# 3. 人工把最终“提交订单”控件准备为可见但不点击;记录精确文案、匹配数和启用态
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_order_confirm_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state submit-control-visible --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-106\submit-control-visible-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
|
||||
# 4. 人工只返回一次并停手;结果不明时不再返回,直接停止
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_order_confirm_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state returned-safe-page --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-106\returned-safe-page-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
```
|
||||
|
||||
四态都必须由项目所有者本地核对截图/XML:规格、数量、Gate3 应付总额、最终控件精确文案/匹配数/
|
||||
启用态、一次返回后的页面身份;同时确认未点击最终提交、未创建订单、未进入支付或安全验证页面。
|
||||
满足全部人工验收前 T-106 保持 `DOING`,这些人工观察也不能在 T-106 中直接变成生产 selector。
|
||||
|
||||
## 关键背景
|
||||
|
||||
@@ -188,14 +261,13 @@ T-103 已确认当前衣服商品只能从精确文案“快要抢光”进入
|
||||
|
||||
## 已知风险(开工前须知)
|
||||
|
||||
1. **M2 是生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、**读到该 SKU 单价**
|
||||
(T-103)。M2 前可以写不依赖页面字段的管理员会话、DRAFT 建单和服务端授权事务;不能写
|
||||
依赖未取证选择器的生产执行代码。真机结论改变字段时先回修契约与原型。
|
||||
1. **M2 已通过**:T-103 已在真机按链接打开商品、精确勾选颜色分类和尺码并读到该 SKU 单价;
|
||||
后续页面能力仍必须逐任务先取证。真机结论改变字段时先回修契约与原型。
|
||||
2. **拼多多页面结构随版本变化**,已观察到详情页无独立规格入口、价格节点被拆分等情况。
|
||||
3. **授权卡死**:前序项目出现过 `EXECUTING` 授权永不推进导致任务锁死。本项目在 T-207
|
||||
实现围栏前超时 / 放弃,在 T-208 实现围栏后调和;围栏后不得释放或重试。
|
||||
4. **规格面板读价尚未形成生产判据**:已人工观察当前价与原价,但 T-103 仍需把证据固化为
|
||||
唯一、版本绑定且排除按钮价的读取规则;失败就转人工,不删价格闸门。
|
||||
4. **确认页能力仍未开放**:T-105 已按本项目真机证据开放数量 1→2、readback 与 Gate2;确认页导航、
|
||||
Gate3 和最终控件仍必须依次等待 T-106/T-107 取证,不能从旧项目或推理直接实现。
|
||||
5. **MVP 已收窄**:只做手工填链接、批量开始采购和单趟创建待付款订单。Excel、ERP、图搜、
|
||||
批量顺序编排 / 暂停接管、订单自动核对、AI 辅助全部推到 V2(见 `06-tasks.md` 的
|
||||
T-501~T-508)。
|
||||
|
||||
@@ -470,6 +470,7 @@
|
||||
<div><dt>颜色分类</dt><dd id="color">—</dd></div>
|
||||
<div><dt>尺码 / 数量</dt><dd id="size-qty">—</dd></div>
|
||||
<div><dt>闸门一单价</dt><dd id="gate1-price">—</dd></div>
|
||||
<div><dt>闸门二面板总额</dt><dd id="gate2-total">—</dd></div>
|
||||
<div><dt>金额上限</dt><dd id="price-cap">—</dd></div>
|
||||
<div class="span-2"><dt>提交围栏</dt><dd id="submission">未建立</dd></div>
|
||||
</dl>
|
||||
@@ -583,7 +584,7 @@
|
||||
const task = {
|
||||
id: 'CB-20260803-0042',
|
||||
product: '纯棉圆领短袖 T 恤(原型假数据)',
|
||||
color: '白色', sizeQty: 'XL / 2 件', gate1Price: 'CNY 32.50', cap: 'CNY 80.00'
|
||||
color: '白色', sizeQty: 'XL / 2 件', gate1Price: 'CNY 32.50', gate2Total: 'CNY 65.00', cap: 'CNY 80.00'
|
||||
};
|
||||
const records = [
|
||||
{
|
||||
@@ -604,15 +605,15 @@
|
||||
id:'REC-DEMO-0038', taskId:'CB-20260803-0038', title:'标签打印纸补货(原型假数据)', collectedAt:'2026-08-03T10:06:42+08:00',
|
||||
status:'WAITING_PAYMENT', spec:'白色 | 50×30 | 1 件', cap:'CNY 50.00', submission:'SUB-DEMO-0038 · SUBMITTED',
|
||||
note:'已明确创建待付款订单,自动化已停止,等待人核对后自行付款。', evidence:['订单确认页内部原始截图(内联假证据)'],
|
||||
timeline:['10:04:22 同一趟精确选择规格','10:05:10 两次价格与数量复核通过','10:05:36 获得唯一围栏许可','10:06:42 明确创建待付款订单'],
|
||||
timeline:['10:04:22 同一趟精确选择规格','10:05:10 Gate1 单价、Gate2 面板总额与 Gate3 确认金额通过','10:05:36 获得唯一围栏许可','10:06:42 明确创建待付款订单'],
|
||||
result:'已创建待付款订单:白色 | 50×30 | CNY 46.00 | 1 件', resultNote:'系统只创建待付款订单,付款始终由人完成。'
|
||||
},
|
||||
{
|
||||
id:'REC-DEMO-0032', taskId:'CB-20260803-0032', title:'仓库打包胶带(原型假数据)', collectedAt:'2026-08-03T09:18:05+08:00',
|
||||
status:'NEEDS_MANUAL', spec:'透明 | 45mm | 4 件', cap:'CNY 55.00', submission:'未建立',
|
||||
note:'设置数量后规格面板价格与闸门一不一致,已在围栏前转人工。', evidence:[],
|
||||
timeline:['09:16:40 打开商品','09:17:22 匹配透明 / 45mm','09:18:05 价格闸门二不通过,停止自动流程'],
|
||||
result:'未采购:闸门一 CNY 12.00 | 闸门二 CNY 13.50', resultNote:'没有建立围栏、创建订单或付款。'
|
||||
note:'目标数量的规格面板总额超过授权上限,已在围栏前转人工。', evidence:[],
|
||||
timeline:['09:16:40 打开商品','09:17:22 匹配透明 / 45mm','09:18:05 闸门二面板总额超限,停止自动流程'],
|
||||
result:'未采购:闸门一单价 CNY 12.00 | 闸门二面板总额 CNY 58.00 > 上限 CNY 55.00', resultNote:'没有建立围栏、创建订单或付款。'
|
||||
}
|
||||
].sort((a, b) => b.collectedAt.localeCompare(a.collectedAt));
|
||||
const recordStatus = {
|
||||
@@ -627,7 +628,7 @@
|
||||
let detailRequestId = 0;
|
||||
const baseSteps = {
|
||||
idle: [],
|
||||
single: ['核对一次性授权并打开商品', '精确选择颜色分类与尺码', '闸门一:读取 SKU 单价并校验上限', '设置数量、读回并执行闸门二', '确认页执行闸门三与控件唯一校验', '申请服务端提交围栏', '首次许可后持久化并内部点击一次', '调和同一提交结果']
|
||||
single: ['核对一次性授权并打开商品', '精确选择颜色分类与尺码', '闸门一:读取 SKU 单价并校验上限', '设置数量、读回并读取闸门二面板总额', '确认页执行闸门三同额与控件唯一校验', '申请服务端提交围栏', '首次许可后持久化并内部点击一次', '调和同一提交结果']
|
||||
};
|
||||
const scenarios = {
|
||||
not_ready: { tone:'critical', title:'web 服务不可达', text:'未开始轮询。检查本地网络和服务地址;已完成的本地证据会保留待补传。', web:'bad', device:'ok', version:'ok', leg:'未就绪', legTone:'critical', action:'开始轮询', enabled:false, reason:'web 服务未就绪,不能领取任务。', session:'未启动', step:'连接检查', kind:'idle', active:-1, countdown:'—', fail:'1 / 3' },
|
||||
@@ -637,8 +638,8 @@
|
||||
claimed: { tone:'info', title:'已领取管理员授权的采购任务', text:'任务字段与最高总价已锁定;当前只准备打开目标商品,尚未建立提交围栏。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 已领取', legTone:'info', action:'真机步骤执行中', enabled:false, reason:'当前任务已开始,停止轮询只应阻止下一次领取,不能硬取消本任务。', session:'运行中', step:'核对授权与商品', kind:'single', active:0, countdown:'暂停', fail:'0 / 3', lease:'04:48' },
|
||||
selecting: { tone:'info', title:'正在精确选择颜色与尺码', text:'只在已取证维度容器内精确唯一匹配并读回;缺失、重复或版本变化立即停止。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 选择规格', legTone:'info', action:'真机步骤执行中', enabled:false, reason:'当前受控步骤不能被硬取消。', session:'运行中', step:'精确选择规格', kind:'single', active:1, countdown:'暂停', fail:'0 / 3', lease:'04:18' },
|
||||
gate1: { tone:'info', title:'闸门一:读取 SKU 单价', text:'当前规格单价必须唯一可读,单价乘数量不得超过管理员授权的最高总价。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 闸门一', legTone:'info', action:'安全校验进行中', enabled:false, reason:'价格不可读或超上限时立即停止。', session:'运行中', step:'闸门一:读取 SKU 单价', kind:'single', active:2, countdown:'暂停', fail:'0 / 3', lease:'03:58' },
|
||||
quantity_gate2: { tone:'info', title:'数量复核与闸门二', text:'数量必须读回为 2;目标规格不能漂移,第二次单价必须等于闸门一 CNY 32.50。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 闸门二', legTone:'info', action:'安全校验进行中', enabled:false, reason:'数量、规格或价格变化都停止。', session:'运行中', step:'数量读回与闸门二', kind:'single', active:3, countdown:'暂停', fail:'0 / 3', lease:'03:31' },
|
||||
confirm_gate3: { tone:'caution', title:'确认页与闸门三', text:'正在核对规格、数量、应付总额与提交控件唯一;此时没有点击许可。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 闸门三', legTone:'caution', action:'安全校验进行中', enabled:false, reason:'没有服务端围栏许可,绝不能点击提交订单。', session:'运行中', step:'闸门三:核对应付总额', kind:'single', active:4, countdown:'暂停', fail:'0 / 3', lease:'02:58' },
|
||||
quantity_gate2: { tone:'info', title:'数量复核与闸门二', text:'数量必须读回为 2;目标规格不能漂移,面板总额 CNY 65.00 必须唯一可读且不超过 CNY 80.00。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 闸门二', legTone:'info', action:'安全校验进行中', enabled:false, reason:'数量、规格、总额歧义或超限都停止。', session:'运行中', step:'数量读回与闸门二', kind:'single', active:3, countdown:'暂停', fail:'0 / 3', lease:'03:31' },
|
||||
confirm_gate3: { tone:'caution', title:'确认页与闸门三', text:'正在核对规格、数量、应付总额严格等于闸门二面板总额,并确认提交控件唯一;此时没有点击许可。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 闸门三', legTone:'caution', action:'安全校验进行中', enabled:false, reason:'没有服务端围栏许可,绝不能点击提交订单。', session:'运行中', step:'闸门三:核对应付总额', kind:'single', active:4, countdown:'暂停', fail:'0 / 3', lease:'02:58' },
|
||||
fencing: { tone:'caution', title:'正在申请服务端提交围栏', text:'等待首次明确的 click_permitted。超时、冲突、网络失败或响应不明都不会点击。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 申请围栏', legTone:'caution', action:'等待围栏响应', enabled:false, reason:'申请中禁止取消、关闭或换 key 重复申请。', session:'运行中', step:'申请唯一提交围栏', kind:'single', active:5, countdown:'暂停', fail:'0 / 3', lease:'02:31' },
|
||||
fence_failed: { tone:'critical', title:'未获得首次明确点击许可', text:'围栏失败或响应不明:没有点击提交订单。只核查同一幂等键,不能直接重试。', web:'bad', device:'ok', version:'ok', leg:'单趟 · 未获许可', legTone:'critical', action:'打开围栏核查说明', enabled:true, safeAction:'review', reason:'只核查围栏状态,不触发提交。', session:'已停止', step:'核查幂等键', kind:'single', active:5, countdown:'—', fail:'1 / 3', submission:'围栏状态未知 · 幂等键 DEMO-FENCE-042' },
|
||||
fenced: { tone:'caution', title:'围栏已建立,只核对同一提交', text:'submission_id 已固定。内部流程至多点击一次;界面不提供提交、重领、放弃或重试。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 已围栏', legTone:'caution', action:'打开同一提交核查说明', enabled:true, safeAction:'review', reason:'只能查看与调和同一提交记录。', session:'受控执行', step:'调和订单结果', kind:'single', active:7, countdown:'暂停', fail:'0 / 3', lease:'01:49', submission:'SUB-DEMO-0042 · FENCED' },
|
||||
@@ -646,7 +647,7 @@
|
||||
uncertain: { tone:'critical', title:'订单可能已创建,结果不明确', text:'保持金额预留和永久围栏,只核查 SUB-DEMO-0042;禁止重新领取、释放授权或再次点击。', web:'ok', device:'ok', version:'ok', leg:'RECONCILIATION_REQUIRED', legTone:'critical', action:'打开人工核查说明', enabled:true, safeAction:'review', reason:'核查不会发起新的下单。可另行导出假证据摘要。', session:'已停止', step:'人工核查同一提交', kind:'single', active:7, countdown:'—', fail:'0 / 3', submission:'SUB-DEMO-0042 · 结果不明确' },
|
||||
security: { tone:'critical', title:'检测到安全校验,已停止', text:'不绕过验证码、风控、人脸或短信。若已围栏,只调和同一提交;围栏前转人工。', web:'ok', device:'ok', version:'ok', leg:'待人工 · 安全校验', legTone:'critical', action:'打开人工核查说明', enabled:true, safeAction:'review', reason:'只显示核查说明,不提供绕过或继续。', session:'已停止', step:'等待人工处理', kind:'single', active:7, countdown:'—', fail:'1 / 3', submission:'SUB-DEMO-0042 · RECONCILIATION_REQUIRED' },
|
||||
payment_handoff: { tone:'critical', title:'进入外部支付交接,已停止', text:'订单可能已创建、支付未完成。不会读取或输入支付凭据,也没有“继续付款”按钮。', web:'ok', device:'ok', version:'ok', leg:'待人工 · 外部支付', legTone:'critical', action:'打开人工核查说明', enabled:true, safeAction:'paymentInfo', reason:'只显示人工核查与付款边界。', session:'已停止', step:'人工核查同一提交', kind:'single', active:7, countdown:'—', fail:'0 / 3', submission:'SUB-DEMO-0042 · HANDED_OFF' },
|
||||
manual: { tone:'caution', title:'任务已转人工:闸门二价格变化', text:'闸门一 CNY 32.50,数量设置后重读 CNY 35.00。围栏未建立,没有创建订单;需管理员核查后重置。', web:'ok', device:'ok', version:'ok', leg:'NEEDS_MANUAL', legTone:'caution', action:'导出假诊断摘要', enabled:true, safeAction:'export', reason:'导出只生成本地假文本,不含完整 XML、页面全文或敏感字段。', session:'继续轮询其他任务', step:'当前任务已移交', kind:'single', active:3, countdown:'00:11', fail:'0 / 3', submission:'未建立' },
|
||||
manual: { tone:'caution', title:'任务已转人工:闸门二总额超限', text:'目标数量面板总额 CNY 85.00 超过最高总价 CNY 80.00。围栏未建立,没有创建订单;需管理员核查后重置。', web:'ok', device:'ok', version:'ok', leg:'NEEDS_MANUAL', legTone:'caution', action:'导出假诊断摘要', enabled:true, safeAction:'export', reason:'导出只生成本地假文本,不含完整 XML、页面全文或敏感字段。', session:'继续轮询其他任务', step:'当前任务已移交', kind:'single', active:3, countdown:'00:11', fail:'0 / 3', submission:'未建立' },
|
||||
failed_stop: { tone:'critical', title:'连续失败达到阈值,轮询已停止', text:'最近三次均为服务连接失败。检查设置和网络;修复后重新开始普通轮询,不会重试任何提交点击。', web:'bad', device:'ok', version:'ok', leg:'自动停止', legTone:'critical', action:'前往配置', enabled:true, safeAction:'settings', reason:'先修复连接;不会自动恢复轮询。', session:'已停止', step:'等待修复', kind:'idle', active:-1, countdown:'—', fail:'3 / 3', count:4 }
|
||||
};
|
||||
const sessionControls = {
|
||||
@@ -834,7 +835,9 @@
|
||||
$('task-id').textContent = hasTask ? task.id : '—'; $('lease').textContent = s.lease || '—';
|
||||
$('product').textContent = hasTask ? task.product : '暂无商品;轮询会在安全就绪后领取一条任务';
|
||||
$('color').textContent = hasTask ? task.color : '—'; $('size-qty').textContent = hasTask ? task.sizeQty : '—';
|
||||
$('gate1-price').textContent = hasTask && ['gate1','quantity_gate2','confirm_gate3','fencing','fence_failed','fenced','waiting_payment','uncertain','security','payment_handoff','manual'].includes($('scenario').value) ? task.gate1Price : '—'; $('price-cap').textContent = hasTask ? task.cap : '—';
|
||||
$('gate1-price').textContent = hasTask && ['gate1','quantity_gate2','confirm_gate3','fencing','fence_failed','fenced','waiting_payment','uncertain','security','payment_handoff','manual'].includes($('scenario').value) ? task.gate1Price : '—';
|
||||
$('gate2-total').textContent = hasTask && ['quantity_gate2','confirm_gate3','fencing','fence_failed','fenced','waiting_payment','uncertain','security','payment_handoff'].includes($('scenario').value) ? task.gate2Total : '—';
|
||||
$('price-cap').textContent = hasTask ? task.cap : '—';
|
||||
$('submission').textContent = s.submission || '未建立';
|
||||
$('preview-empty').hidden = hasTask; $('product-preview-image').hidden = !hasTask;
|
||||
$('preview-source').textContent = hasTask ? '来源:内部原始商品截图(内联假证据)' : '来源:尚无可信图片';
|
||||
|
||||
@@ -26,22 +26,22 @@
|
||||
|
||||
<article class="state-panel" data-panel="draft">
|
||||
<header class="panel-head"><h2>待开始</h2><p>任务已保存,但尚未授权采购工具领取或创建订单。</p></header>
|
||||
<div class="panel-body"><div class="notice"><strong>创建不等于授权</strong>请返回任务列表勾选任务,并点击“开始采购(只创建待付款订单)”。</div><dl class="summary"><div><dt>商品</dt><dd>goods_id 937122477375</dd></div><div><dt>颜色 / 尺码</dt><dd>黑色CHA(纯棉) / M(建议100-115)</dd></div><div><dt>数量</dt><dd>2 件</dd></div><div><dt>最高总价</dt><dd>¥30.00</dd></div></dl><div class="actions"><a class="button primary" href="web-task-workbench.html">返回列表选择并开始采购</a></div></div>
|
||||
<div class="panel-body"><div class="notice"><strong>创建不等于授权</strong>请返回任务列表勾选任务,并点击“开始采购(只创建待付款订单)”。</div><dl class="summary"><div><dt>商品</dt><dd>goods_id 937122477375</dd></div><div><dt>颜色 / 尺码</dt><dd>黑色CHA(纯棉) / M(建议100-115)</dd></div><div><dt>数量</dt><dd>2 件</dd></div><div><dt>最高总价</dt><dd>¥40.00</dd></div></dl><div class="actions"><a class="button primary" href="web-task-workbench.html">返回列表选择并开始采购</a></div></div>
|
||||
</article>
|
||||
|
||||
<article class="state-panel" data-panel="pending" hidden>
|
||||
<header class="panel-head"><h2>已授权,等待采购工具领取</h2><p>管理员的开始采购动作已锁定任务字段和最高总价。</p></header>
|
||||
<div class="panel-body"><div class="notice ok"><strong>一次性授权已创建</strong>允许采购工具按本任务创建一笔待付款订单;不授权付款。</div><dl class="summary"><div><dt>授权编号</dt><dd class="mono">AUTH-018-V2</dd></div><div><dt>任务版本</dt><dd>2</dd></div><div><dt>最高总价</dt><dd>¥30.00</dd></div><div><dt>提交围栏</dt><dd>尚未建立</dd></div></dl><p>本状态没有“机器选对了吗”按钮。规格与价格将在设备执行时实时校验。</p></div>
|
||||
<div class="panel-body"><div class="notice ok"><strong>一次性授权已创建</strong>允许采购工具按本任务创建一笔待付款订单;不授权付款。</div><dl class="summary"><div><dt>授权编号</dt><dd class="mono">AUTH-018-V2</dd></div><div><dt>任务版本</dt><dd>2</dd></div><div><dt>最高总价</dt><dd>¥40.00</dd></div><div><dt>提交围栏</dt><dd>尚未建立</dd></div></dl><p>本状态没有“机器选对了吗”按钮。规格与价格将在设备执行时实时校验。</p></div>
|
||||
</article>
|
||||
|
||||
<article class="state-panel" data-panel="ordering" hidden>
|
||||
<header class="panel-head"><h2>单趟采购执行中</h2><p>采购工具正在同一设备会话中完成规格、价格、数量和确认页校验。</p></header>
|
||||
<div class="panel-body"><ol class="timeline"><li><strong>已打开目标商品</strong>App 8.17.0;goods_id 已核对</li><li><strong>已精确选择规格</strong>黑色CHA(纯棉) / M(建议100-115)</li><li><strong>闸门一通过</strong>SKU 单价 ¥12.88;预计总额 ¥25.76</li><li><strong>数量已读回</strong>2 件;正在执行闸门二</li><li><strong>尚未建立提交围栏</strong>不会点击提交订单</li></ol><div class="notice warn"><strong>无需中途人工确认</strong>任一读数变化或有歧义,系统会停止并转人工。</div></div>
|
||||
<div class="panel-body"><ol class="timeline"><li><strong>已打开目标商品</strong>App 8.17.0;goods_id 已核对</li><li><strong>已精确选择规格</strong>黑色CHA(纯棉) / M(建议100-115)</li><li><strong>闸门一通过</strong>SKU 单价 ¥12.88;数量前预检通过</li><li><strong>闸门二通过</strong>数量读回 2 件;面板总额 ¥32.76 ≤ ¥40.00</li><li><strong>尚未建立提交围栏</strong>不会点击提交订单</li></ol><div class="notice warn"><strong>无需中途人工确认</strong>任一读数变化或有歧义,系统会停止并转人工。</div></div>
|
||||
</article>
|
||||
|
||||
<article class="state-panel" data-panel="fenced" hidden>
|
||||
<header class="panel-head"><h2>提交围栏已建立</h2><p>只允许处理这一个 submission,不允许释放或再次提交。</p></header>
|
||||
<div class="panel-body"><div class="notice danger"><strong>禁止重试</strong>唯一提交记录 <span class="mono">SUB-018-5A7C</span> 已占用本授权;关闭页面也不会释放。</div><table class="gates"><thead><tr><th>门禁</th><th>读取值</th><th>结论</th></tr></thead><tbody><tr><td>闸门一</td><td>¥12.88 × 2 = ¥25.76</td><td class="good">通过</td></tr><tr><td>闸门二</td><td>规格不变;¥12.88</td><td class="good">通过</td></tr><tr><td>闸门三</td><td>确认页 ¥25.76 ≤ ¥30.00</td><td class="good">通过</td></tr><tr><td>提交控件</td><td>精确唯一 1 个</td><td class="good">通过</td></tr></tbody></table><div class="actions"><button class="button primary" type="button" data-state="reconcile">查看同一提交的调和页</button></div></div>
|
||||
<div class="panel-body"><div class="notice danger"><strong>禁止重试</strong>唯一提交记录 <span class="mono">SUB-018-5A7C</span> 已占用本授权;关闭页面也不会释放。</div><table class="gates"><thead><tr><th>门禁</th><th>读取值</th><th>结论</th></tr></thead><tbody><tr><td>闸门一</td><td>单价 ¥12.88;数量前预检通过</td><td class="good">通过</td></tr><tr><td>闸门二</td><td>规格 / 数量不变;面板总额 ¥32.76 ≤ ¥40.00</td><td class="good">通过</td></tr><tr><td>闸门三</td><td>确认页 ¥32.76 = 闸门二;≤ ¥40.00</td><td class="good">通过</td></tr><tr><td>提交控件</td><td>精确唯一 1 个</td><td class="good">通过</td></tr></tbody></table><div class="actions"><button class="button primary" type="button" data-state="reconcile">查看同一提交的调和页</button></div></div>
|
||||
</article>
|
||||
|
||||
<article class="state-panel" data-panel="reconcile" hidden>
|
||||
@@ -51,12 +51,12 @@
|
||||
|
||||
<article class="state-panel" data-panel="payment" hidden>
|
||||
<header class="panel-head"><h2>待付款</h2><p>订单已创建,系统尚未付款;请人工核对。</p></header>
|
||||
<div class="panel-body"><figure class="evidence"><svg viewBox="0 0 760 260" role="img" aria-labelledby="ev-title ev-desc"><title id="ev-title">内部订单截图假数据</title><desc id="ev-desc">只表示证据位置,不代表真实拼多多布局。</desc><rect width="760" height="260" fill="#e8eef7"/><rect x="30" y="25" width="700" height="210" rx="10" fill="#fff" stroke="#b8c4d5"/><text x="55" y="65" fill="#526079" font-size="18">INTERNAL_RAW 截图占位 · 假数据</text><text x="55" y="115" fill="#172033" font-size="19">黑色CHA(纯棉) · M(建议100-115) · 2 件</text><text x="55" y="165" fill="#172033" font-size="24">确认页金额 ¥25.76</text><text x="55" y="205" fill="#9a5b00" font-size="18">订单已创建,尚未付款</text></svg><figcaption>原始截图只供已登录管理员查看;完整 XML 和支付凭据不上传。</figcaption></figure><div class="checks"><label><input type="checkbox" data-pay-check>商品与任务一致</label><label><input type="checkbox" data-pay-check>规格和数量一致</label><label><input type="checkbox" data-pay-check>金额不超过授权上限 ¥30.00</label><label><input type="checkbox" data-pay-check>我已在拼多多人工完成付款</label></div><div class="actions"><button class="button primary" id="mark-paid" type="button" disabled>标记为已付款并完成</button></div></div>
|
||||
<div class="panel-body"><figure class="evidence"><svg viewBox="0 0 760 260" role="img" aria-labelledby="ev-title ev-desc"><title id="ev-title">内部订单截图假数据</title><desc id="ev-desc">只表示证据位置,不代表真实拼多多布局。</desc><rect width="760" height="260" fill="#e8eef7"/><rect x="30" y="25" width="700" height="210" rx="10" fill="#fff" stroke="#b8c4d5"/><text x="55" y="65" fill="#526079" font-size="18">INTERNAL_RAW 截图占位 · 假数据</text><text x="55" y="115" fill="#172033" font-size="19">黑色CHA(纯棉) · M(建议100-115) · 2 件</text><text x="55" y="165" fill="#172033" font-size="24">确认页金额 ¥32.76</text><text x="55" y="205" fill="#9a5b00" font-size="18">订单已创建,尚未付款</text></svg><figcaption>原始截图只供已登录管理员查看;完整 XML 和支付凭据不上传。</figcaption></figure><div class="checks"><label><input type="checkbox" data-pay-check>商品与任务一致</label><label><input type="checkbox" data-pay-check>规格和数量一致</label><label><input type="checkbox" data-pay-check>金额不超过授权上限 ¥40.00</label><label><input type="checkbox" data-pay-check>我已在拼多多人工完成付款</label></div><div class="actions"><button class="button primary" id="mark-paid" type="button" disabled>标记为已付款并完成</button></div></div>
|
||||
</article>
|
||||
|
||||
<article class="state-panel" data-panel="manual" hidden>
|
||||
<header class="panel-head"><h2>围栏前需要人工处理</h2><p>系统已经停止,未申请围栏、未点击提交订单。</p></header>
|
||||
<div class="panel-body"><div class="notice danger"><strong>闸门二未通过</strong>闸门一 ¥12.88,数量设置后重读 ¥13.20;不使用新价格继续。</div><dl class="summary"><div><dt>失败阶段</dt><dd>规格面板 · 闸门二</dd></div><div><dt>原因码</dt><dd class="mono">SKU_PRICE_CHANGED</dd></div><div><dt>安全处置</dt><dd>停止并保留证据</dd></div><div><dt>可用动作</dt><dd>人工核查后重置为待开始或取消</dd></div></dl><div class="actions"><button class="button danger" id="reset-button" type="button">关闭旧授权并重置为待开始</button></div></div>
|
||||
<div class="panel-body"><div class="notice danger"><strong>闸门二未通过</strong>目标数量面板总额 ¥42.00 超过最高总价 ¥40.00;围栏未建立。</div><dl class="summary"><div><dt>失败阶段</dt><dd>规格面板 · 闸门二</dd></div><div><dt>原因码</dt><dd class="mono">GATE2_TOTAL_OVER_CAP</dd></div><div><dt>安全处置</dt><dd>停止并保留证据</dd></div><div><dt>可用动作</dt><dd>人工核查后重置为待开始或取消</dd></div></dl><div class="actions"><button class="button danger" id="reset-button" type="button">关闭旧授权并重置为待开始</button></div></div>
|
||||
</article>
|
||||
|
||||
<article class="state-panel" data-panel="succeeded" hidden><header class="panel-head"><h2>任务已完成</h2><p>人工付款并核对后才进入终态。</p></header><div class="panel-body"><div class="notice ok"><strong>人工付款事实已记录</strong>系统没有执行付款,也没有读取支付凭据。</div><div class="actions"><a class="button primary" href="web-task-workbench.html">返回任务列表</a></div></div></article>
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
<aside class="panel side" aria-label="原型状态与任务摘要">
|
||||
<section><h2>原型状态</h2><p>只切换假数据,不发送请求。</p><label class="field-label" for="state-select">业务状态</label><select id="state-select"><option value="draft">DRAFT</option><option value="pending">PENDING</option><option value="ordering">ORDERING</option><option value="fenced">FENCED</option><option value="reconcile">RECONCILIATION_REQUIRED</option><option value="payment">WAITING_PAYMENT</option><option value="manual">NEEDS_MANUAL</option><option value="succeeded">SUCCEEDED</option><option value="failed">FAILED</option><option value="canceled">CANCELED</option></select></section>
|
||||
<section><h2>任务要求</h2><p>黑色CHA(纯棉)<br>M(建议100-115)<br>2 件<br>最高总价 ¥30.00</p></section>
|
||||
<section><h2>任务要求</h2><p>黑色CHA(纯棉)<br>M(建议100-115)<br>2 件<br>最高总价 ¥40.00</p></section>
|
||||
<section><h2>固定边界</h2><p>单趟实时三闸门;服务端围栏;提交一次;系统不付款。</p></section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@
|
||||
1. 任务要求:商品链接 / goods_id、颜色、尺码、数量、最高总价、版本。
|
||||
2. 开始采购授权:授权 id、授权人、锁定任务版本、创建/有效期、当前状态。
|
||||
3. 设备执行:attempt、设备、App 版本、步骤时间线和失败 code。
|
||||
4. 三道闸门:两次规格面板单价、规格/数量读回、确认页总额与判定。
|
||||
4. 三道闸门:闸门一规格面板单价、规格/数量读回、闸门二面板总额、确认页同额校验与判定。
|
||||
5. 内部截图:三道闸门已批准的规格面板/确认页证据;只经受保护端点读取。MVP 不存在提交后结果
|
||||
截图类型,不展示外部支付页,也不把 Gate3 图片冒充结果图。
|
||||
6. 提交围栏:submission id、是否首次明确许可、唯一点击和调和记录。
|
||||
|
||||
+250
-31
@@ -3,7 +3,7 @@ id: T-103
|
||||
title: 验证规格面板精确选择与 SKU 单价
|
||||
phase: 1
|
||||
deps: [T-102, T-110, T-111]
|
||||
status: DOING
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 23
|
||||
context_ref: 1dc8308
|
||||
@@ -17,45 +17,52 @@ write_paths:
|
||||
- client/tests/pdd/**
|
||||
- client/tests/device/**
|
||||
- client/scripts/capture_sku_panel_spike.py
|
||||
- client/scripts/capture_sku_reveal_spike.py
|
||||
- client/scripts/run_t103_sku_selection.py
|
||||
- client/scripts/sanitize_sku_panel_evidence.py
|
||||
- docs/03-tech-stack.md
|
||||
- docs/04-architecture.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-04T10:47:47Z sha256=5471bdfeed1fffe4f0abd25201b9d5bf7bf55fa9500c11aa297eb4c422da5f02 -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-05T09:03:05Z sha256=2a81aa73a233e09b2b9418aba9a6f298428bba12371110205a0f782fb90787ca -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:规格面板由详情页精确唯一的“快要抢光”打开;T-110 已把该证据/版本绑定入口批准为受控导航。面板刚打开时目标颜色“黑色CHA(纯棉)”和尺码“M(建议100-115)”自动选中。
|
||||
T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:详情页底部购买区第一行“快要抢光 + 金额”是唯一获准的受控规格入口;商品内容区同名小字和第二行“免拼购买”都不得点击。
|
||||
|
||||
项目所有者已把 MVP 改为单趟:管理员点击“开始采购”即授权采购工具按锁定的商品、规格、数量和最高总价创建待付款订单,不再有试选后确认。T-103 仍只验证规格选择与读价,是生产单趟的窄前置;数量、确认页、提交围栏和提交订单由后续取证任务开放。系统绝不自动付款。
|
||||
2026-08-05 的五组最新真机证据纠正了旧结论:规格面板刚打开时颜色和尺码均未选中,摘要为“请选择:颜色分类 尺码”;选择目标颜色后尺码仍未选且不在视口;滚动后尺码选项可见。人工已补齐 S 非目标值与恢复目标 M 的前后证据,但现有滚动后证据中的 M 已选中,尚不能证明滚动动作本身不会误选尺码。
|
||||
|
||||
`t103-privacy-v5` 派生 XML 已由人确认保留目标规格、顶部当前价“快卖完 ¥12.88”和原价“¥29.88”,且不含实际地址/手机号;底部“提交订单 ¥12.88”是硬拒绝区。项目已停止遮罩器开发。正式流程允许把规格/确认页内部原始截图上传采购服务,完整 XML 只留客户端本机。
|
||||
项目为单趟 MVP:管理员点击“开始采购”授权采购工具创建待付款订单,但 T-103 仍只是规格选择与闸门一读价的窄前置。数量、确认页、提交围栏、提交订单和支付能力不属于本任务;系统绝不自动付款。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-006 单趟采购的规格面板前置;覆盖 `open_sku_panel()`、`select_sku_options()`、`read_sku_unit_price()` 和安全退出的真机 spike。
|
||||
- 功能:F-006 单趟采购的规格面板前置;覆盖 `open_sku_panel()`、精确颜色/尺码选择、`read_sku_unit_price()`、本机原始截图与一次安全 Back。
|
||||
- 架构/API:`docs/04-architecture.md` 第三、四、5.4、六节;`docs/api.md` 的 `SkuSelectionFlow`;T-104、T-105 与 T-205 的前置。
|
||||
- 本任务没有生产 GUI,也不调用 T-204 的 HTTP 截图链路。
|
||||
- 本任务没有生产 GUI,不调用 T-204 的 HTTP 截图链路;完整 XML 只留本机。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 停止修改、调用或扩展截图遮罩器。T-103 原始截图保存在本机产物目录完成真机验收;T-204 以后显式上传 `privacy_tier=INTERNAL_RAW` 的截图。完整 XML 不上传、不进日志、Vikunja 或 Git。
|
||||
2. 从已确认 v5 派生 XML 提取只含页面判据节点的最小 fixture;fixture 不含地址、手机号或支付凭据。
|
||||
3. 实现 `SkuSelectionFlow` 窄 capability。`open_sku_panel()` 只允许拼多多 8.17.0、goods_id `937122477375` 上证据绑定、精确唯一的“快要抢光”;缺失、重复、版本失配或面板不唯一时零后续点击。其他购买文案不得复用。
|
||||
4. 本任务只含打开商品、受控打开面板、维度内精确选择、选中态读回、读取 SKU 单价、原始截图和安全退出。不得暴露通用 `click`、数量、确认页、授权、提交围栏、提交订单或支付能力。
|
||||
5. 规格选项按维度容器隔离后精确唯一匹配,防“红/粉红”“1/10”前缀碰撞;缺失、重复、禁用、维度不明或选中态不唯一均停止。
|
||||
6. 单价只从已取证规格面板当前价角色读取,严格解析人民币十进制字符串。促销前缀可变,不能把“快卖完”作为主判据;必须限定 PDD package、面板结构/相对位置、非点击价格叶节点、唯一当前价并排除原价、提交/支付/优惠语义。歧义返回 unreadable,不从详情页或底部按钮补值。
|
||||
7. 真机运行只返回规格、选中态、价格和页面状态摘要;地址/手机号不解析为字段。截图上传器不属于本任务。
|
||||
8. 离线主审通过后,由人先把颜色和尺码改成记录的非目标值,再运行脚本恢复目标规格、读取 12.88、保存原始截图并安全退出。`needs_device=true`,agent 保持 DOING 等人工验收。
|
||||
1. 以五组 2026-08-05 真机证据替换“面板打开即自动预选”的旧事实。证据均绑定 PKG110 / Android 16 / 拼多多 8.17.0 / Wi-Fi / goods_id `937122477375`;从中提取不含地址、手机号或支付凭据的最小白名单 fixture,不提交原始截图或完整 XML。
|
||||
2. 把面板状态拆为整体 profile,禁止跨 profile 拼装:`PANEL_OPEN_EMPTY`(两维未选、尺码隐藏)、`COLOR_SELECTED_SIZE_HIDDEN`(仅目标颜色已选)、`SIZE_VISIBLE_NON_TARGET`(目标颜色 + S)、`TARGETS_SELECTED`(目标颜色 + M)。摘要、容器、节点类型、选中态和相对结构任一漂移均 fail closed。
|
||||
3. `open_sku_panel()` 沿用已审底部入口链,只点击第一行文本中心一次且不重试;打开后的初态判据不得要求尺码 option 已在视口或已有 selected。
|
||||
4. 目标颜色只在颜色容器内按 exact content-desc 唯一匹配;从未选到选中只点击一次,并以目标颜色 selected=true、摘要 exact“请选择:尺码”双重读回。
|
||||
5. 尺码显示只允许私有、命名的 `reveal_size_options()` 窄能力:仅可从“目标颜色已选、尺码隐藏且未选”状态调用,绑定已取证的嵌套滚动容器与列间安全通道,最多一次、不重试,不暴露通用 swipe/scroll/任意坐标。生产动作落地前,只允许独立 `capture_sku_reveal_spike.py` 在严格前置成立后执行一次固定无参手势并采集 before/after;生产 Flow 不得调用该 spike。只有人确认动作前后 M 均未选、尺码已显示且提交区未触发后,才能把后置固化为生产判据。
|
||||
6. 尺码可见时,S/M 必须是证据绑定的 exact clickable TextView;恢复流程只允许 S=true/M=false → 点击 M 一次 → S=false/M=true,并用 exact 已选摘要双重读回。生产未选态也必须证明 M=false 后才点击。
|
||||
7. 闸门一单价只在目标颜色和目标 M 都精确选中后读取。只接受已取证滚动态面板价格行中、PDD package、只读、无可点击祖先、唯一的当前价;严格返回十进制字符串 `12.88`,明确区分原价 `29.88` 和硬拒绝区“提交订单 ¥12.88”。未知前缀、候选重复、节点拆分或结构漂移均 unreadable,不从初态、详情页或按钮补值。
|
||||
8. 一次 Back 在发送前封存且不重试;“面板消失”不得自动升级为生产安全退出结论。精确同商品退出判据由依赖任务 T-104 另行真机取证并收紧。
|
||||
9. 真机 runner 只返回规格、选中态、价格、截图路径和固定阶段摘要;不解析地址/手机号为业务字段。T-103 不再修改或调用截图遮罩器。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 最小 fixture 只来自本项目拼多多 8.17.0、goods_id `937122477375` 已验收的 v5 派生 XML,不含地址、手机号或支付凭据。
|
||||
- 受控入口、规格容器、精确选择、选中态和当前价任一歧义均 fail closed;价格能区分 12.88、原价 29.88 与硬拒绝区“提交订单 ¥12.88”。
|
||||
- 静态 API/导入测试证明 `SkuSelectionFlow` 无通用点击、数量、确认页、授权、围栏、提交或支付能力。
|
||||
- T-103 不再开发遮罩。T-204 的原始截图上传必须限制为显式截图文件、设备鉴权、SHA-256 和管理员会话访问,不上传完整 XML 或支付凭据。
|
||||
- fixture 分别来自本项目 8.17.0 的未选初态、仅目标颜色态、S 态和恢复 M 态;旧 v5 全选 fixture 不得冒充初态,fixture 不含地址、手机号或支付凭据。
|
||||
- 四态 classifier 正例通过;package、profile、父子关系、节点类型、摘要、选中态、坐标/相对结构任一漂移和跨 profile 拼装全部拒绝。
|
||||
- 颜色 exact 唯一点击一次;缺失、重复、禁用、近似文本、读回不一致均零后续动作。
|
||||
- 在生产 reveal 落地前,独立 spike 必须补齐“请选择尺码 / M=false / 隐藏 → 单次受控 reveal → 请选择尺码 / M=false / 可见”的真机动作证据;CLI 不接受颜色、尺码、坐标或手势参数。RPC 正常或不明后都只读调和,页面不变、颜色/摘要漂移、M 意外选中、尺码仍隐藏或提交区风险均不重试、不发布;成功后不 Back,等待人核对。
|
||||
- S=true/M=false → 仅点击 exact M 一次 → S=false/M=true 且摘要 exact;任何歧义零点击。
|
||||
- 价格测试能区分当前价 12.88、原价 29.88、初态价格与底部提交按钮价;未知前缀、可点击祖先、多个候选、拆分货币全部 unreadable。
|
||||
- 静态 API/AST/导入测试证明 Flow/Protocol/runner 无公开通用 swipe/scroll/click、数量、确认页、授权、围栏、提交或支付能力,T-103 调用链不可达下单函数。
|
||||
- client 全部单测、compileall、wheel metadata、上下文校验、完整 init 与 diff-check 通过。
|
||||
- 只有人完成受控入口、精确规格选择、SKU 单价语义、页面对应性、原始截图与安全退出验收后才能 DONE。
|
||||
- 只有人完成入口 → 目标颜色 → 单次受控 reveal → 目标 M → 读取 12.88 → 一次安全 Back 的真机验收后才能 DONE;agent 始终保持 Doing 等人工结论。
|
||||
|
||||
## 执行记录
|
||||
|
||||
@@ -182,25 +189,235 @@ T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space
|
||||
### 2026-08-04T10:47:38Z · ila
|
||||
|
||||
2026-08-04 T-103 入口父链收紧完成:提交 44586fe,合入主分支 ce9d6ca。入口只允许精确唯一“快要抢光”子节点及五层已取证可点击 PDD 祖先链,实际点击仍取子节点中心;“免拼购买”独立容器为硬拒绝。独立审计复算证据 hash、验证 focused 32 / full 112 tests 和完整 init 均 PASS。最终真机运行曾安全失败且未发布输出目录;随后只读诊断发现系统通知栏覆盖,app_current 虽仍报告 PDD,但节点树 64 个节点全部属于 SystemUI。下一次必须由人先完全收起通知栏并停在 PDD 首页再运行;T-103 保持 DOING,不把这次失败或入口截图确认误记为完整验收。
|
||||
|
||||
### 2026-08-05T01:19:13Z · ila
|
||||
|
||||
2026-08-05 T-103 人工复跑与失败阶段诊断收口:
|
||||
|
||||
- 人工复跑先因 NotificationShade 覆盖和 USB/Wi-Fi 双 ADB 通道同时在线而安全失败;收敛为仅保留 Wi-Fi ADB、拼多多前台为 HomeActivity 后,仍以 generic failure 安全停止且未发布证据目录。
|
||||
- 主线提交 `870e5f8` 为真机 CLI 增加只来自固定白名单的 failure stage;异常正文、serial、路径与 XML 均不回显,hostile getter、`str` 子类和 setter 全部失败闭合为 `unknown`,原异常类型、finally 安全调和、单击不重试与失败不发布语义保持不变。
|
||||
- 独立终审 PASS;T-103 33 tests PASS,client 全量 233 tests PASS,仓库根 `init.ps1` PASS。
|
||||
- 尚待项目所有者使用新 CLI 再次真机复跑;T-103 保持 done=false / Doing,并继续保留 needs_device 与 needs_human_review 门禁。
|
||||
|
||||
### 2026-08-05T02:14:16Z · ila
|
||||
|
||||
2026-08-05 T-103 真机复跑与入口稳定性证据:
|
||||
|
||||
- 项目所有者复跑返回 `stage=sku_entry`,流程安全停止且未发布本地证据目录。
|
||||
- root 只读保存本机证据 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-entry-stability-937122477375-20260805-093541`,manifest 位于同目录。证据绑定 PKG110 / Android 16 / 拼多多 8.17.0 / goods_id `937122477375` / 1080×2376;5 份完整 XML 的 hash 与节点数发生变化,但完整、精确的入口动作子树投影连续一致,截图由项目所有者确认是目标商品页。
|
||||
- 同时确认 SystemUI“肉包采购辅助”浮窗覆盖入口中心,会截获入口点击;因此真机复跑前必须先关闭该浮窗。
|
||||
- 提交 `a09c58b`:pre-intent 只按完整物理旧链防止误点;动态整页稳定性改为精确入口动作子树连续稳定;任何覆盖入口中心的 live clickable 节点一律拒绝;危险语义和重复入口继续拒绝。入口仍只点击一次且不重试,不包含数量、确认页、提交或支付能力。
|
||||
- 验证通过:聚焦测试 9/9、T-103 40/40、仓库根 `init.ps1` PASS(client 240 项)。
|
||||
- `needs_device=true` / `needs_human_review=true`,T-103 继续 Doing;等待项目所有者关闭浮窗、返回拼多多首页并再次真机复跑。
|
||||
|
||||
### 2026-08-05T02:15:29Z · ila
|
||||
|
||||
更正 comment 105:最新 sku-entry-stability 证据的 screenshot.png 由 root 只读视觉核对为目标商品页;项目所有者尚未对本次证据做最终人工页面对应性验收。该更正不改变 T-103 Doing/needs_device/needs_human_review。
|
||||
|
||||
### 2026-08-05T02:33:17Z · ila
|
||||
|
||||
2026-08-05 T-103 第二次 sku_entry 复跑与安全子阶段诊断:
|
||||
|
||||
- 项目所有者关闭“肉包采购辅助”浮窗、force-stop `com.oplus.games`、仅保留 Wi-Fi ADB,并确认拼多多 `MainFrameActivity` 首页后再次复跑;仍返回 `stage=sku_entry`,安全停止且未发布目录。
|
||||
- root 只读核对失败后的商品页:拼多多 `NewPageActivity`,`physical=1`、`safe projection=true`、`eligible=1`、`panel=false`;点击中心仅存在唯一 PDD action,连续 3 帧入口动作子树 projection hash 一致。
|
||||
- 项目所有者返回首页后,root 只读核对 `physical=0`。因此排除当前首页旧链误判、商品入口结构漂移和浮窗覆盖,剩余待区分范围为 click RPC/点击生效与 panel postcondition 两段。
|
||||
- 提交 `b754cc9` 新增固定、安全且带 Flow provenance 的四个子阶段:`sku_entry_pre_intent`、`sku_entry_discovery`、`sku_entry_click`、`sku_entry_panel_verify`。本次不修改页面判据;入口仍单击且不重试,finally 安全调和、失败不发布和 T-103 能力边界保持不变。
|
||||
- 验证通过:聚焦 5/5、T-103 43/43、compileall、agent-context、diff-check。依项目所有者“最小验收即停止”的要求,本次未重复完整全量门禁。
|
||||
- T-103 继续 Doing,保留 `needs_device` / `needs_human_review`;等待更新代码后从拼多多首页再次复跑并反馈新的安全子阶段。
|
||||
|
||||
### 2026-08-05T02:57:05Z · ila
|
||||
|
||||
2026-08-05 T-103 `sku_entry_panel_verify` 根因取证与入口修正:
|
||||
|
||||
- 项目所有者复跑返回 `stage=sku_entry_panel_verify`;流程安全停止且未发布目录。
|
||||
- root 只读取证目录 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\panel-verify-failure-20260805-103948`。manifest 绑定 PKG110 / Android 16 / 拼多多 8.17.0 / Wi-Fi;截图 SHA-256 为 `4814084b…310eed`,XML SHA-256 为 `bf566a0e…f88e2`。
|
||||
- 根因:旧实现误点内容区小字 `[900,1312][1056,1355]`,中心 `(978,1333)`。正确入口是底部第一行 leaf `[688,2184][1042,2253]`,中心 `(865,2218)`,直接可点击父节点为 `[446,2166][1080,2328]`;同父不可点击 sibling“免拼购买”为 `[688,2256][856,2305]`。
|
||||
- 架构边界先由 commit `81a44ca` 更正并推送。代码已修正为只点击底部第一行;旧 header 必须零点击,sibling 只参与结构校验,按钮金额不作为价格来源,入口仍只点击一次且不重试。
|
||||
- root 独立验收通过:T-103 模块 46/46、compileall、真实证据 selector `entries=1 center=(865,2218)`、agent-context、diff-check。
|
||||
- T-103 继续 Doing,并保留 `needs_device` / `needs_human_review`;等待项目所有者从拼多多首页再复跑一次。
|
||||
|
||||
### 2026-08-05T03:23:09Z · ila
|
||||
|
||||
2026-08-05 正确底部入口的实时规格面板新证据:
|
||||
|
||||
- 初态目录:`C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-panel-live-20260805-111435`。环境绑定拼多多 8.17.0 / PKG110 / Android 16 / Wi-Fi;screenshot SHA-256 `b06872579f094ac714d7b1b1fc52d489d844707f5a9f62f2b4000e1042f8a997`,XML SHA-256 `24a668e5ba8a2f9b95208ef224cdf548aaee610caf516c5c524a9c8f1fefec5c`。
|
||||
- 初始 `selected-count=0`;summary 为 `请选择: 颜色分类 尺码`,bounds `[396,731][1053,793]`;目标颜色是唯一 ViewGroup,`desc='黑色 CHA (纯棉)'`,bounds `[372,1188][684,1587]`,`selected=false`。
|
||||
- 项目所有者人工点击目标颜色后的目录:`C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-color-selected-20260805-111739`。screenshot SHA-256 `1ce89d0d3d4d663e31de467a84472d5cd66b945c7df5ba606592e928431a719f`,XML SHA-256 `e1c31eaef8d6c2af8791a999508909bb4ce6d1445128354107a5a7672c9ba3b7`。
|
||||
- 点击后目标颜色 parent 为 `selected=true`,summary 为 `请选择: 尺码`;目标尺码尚未出现在节点树中,尺码标签仅位于底部边缘。
|
||||
- 当前证据否定了旧的“正确入口打开后目标预选、两个维度同屏可见、沿用旧绝对坐标”假设。在取得滚动后的尺码态与全部选中态证据前,不得修改规格选择判据。
|
||||
- T-103 继续 Doing,并保留 `needs_device` / `needs_human_review`。
|
||||
|
||||
### 2026-08-05T03:33:56Z · ila
|
||||
|
||||
2026-08-05 五态真机证据补齐与结论纠正:
|
||||
|
||||
- 初始未选:C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-panel-live-20260805-111435;screenshot SHA-256 b06872579f094ac714d7b1b1fc52d489d844707f5a9f62f2b4000e1042f8a997;XML SHA-256 24a668e5ba8a2f9b95208ef224cdf548aaee610caf516c5c524a9c8f1fefec5c。
|
||||
- 目标颜色已选、尺码隐藏:...\sku-color-selected-20260805-111739;screenshot 1ce89d0d3d4d663e31de467a84472d5cd66b945c7df5ba606592e928431a719f;XML e1c31eaef8d6c2af8791a999508909bb4ce6d1445128354107a5a7672c9ba3b7。
|
||||
- 尺码可见、M 已选:...\sku-size-visible-20260805-112300;screenshot 74acd371444e64f95358c5d2dad2b3aecc7c5531da1b405af574becf19fd0fd7;XML fbbd30f78794d1936d06d613fa6e5989b1d1f29f535743db8207e416aab5a5a3。
|
||||
- 人工选择非目标 S:...\sku-size-s-selected-20260805-112602;screenshot de9307683128ec2f806e6e1a69b6b6dace757d2b89ea1163e8ec7058d01c33e4;XML 1c79eb2be3b26af53ad68b2fa1c30b51ea320efeabdadf3c3f53a50a6359e6d1。
|
||||
- 人工恢复目标 M:...\sku-size-m-restored-20260805-112704;screenshot fbab377aa040bca42b386680729b12d518bfc32e1c7efa595dac98fcdb5f5675;XML bc97e2cccfd4463a957fac2e246429755bd557fb95f05b8cc599b84b546a5e36;项目所有者已明确确认“M尺码已恢复”。
|
||||
|
||||
五组均为 PKG110 / Android 16 / Wi-Fi / 拼多多 8.17.0。由此否定旧“打开即目标预选”事实并证明 S→M 精确恢复前后态。仍缺唯一动作证据:颜色已选、摘要仍请选择尺码且 M=false/隐藏,经一次受控 reveal 后 M 仍 false 且可见;当前滚动后证据中 M 已经 selected,不能据此猜测通用 swipe。T-103 继续 Doing,保留 needs_device / needs_human_review。
|
||||
|
||||
### 2026-08-05T03:36:51Z · ila
|
||||
|
||||
补充 comment 111 中省略的完整本机证据路径(只记录路径与 SHA,不上传证据正文):
|
||||
- C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-size-visible-20260805-112300
|
||||
- C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-size-s-selected-20260805-112602
|
||||
- C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-size-m-restored-20260805-112704
|
||||
其余环境、哈希、人工 M 恢复结论与 comment 111 一致。
|
||||
|
||||
### 2026-08-05T03:44:34Z · ila
|
||||
|
||||
2026-08-05 reveal 取证方案收口:不改造只读 capture_sku_panel_spike.py,也不把未经验证的滑动放进生产 SkuSelectionFlow。新增独立 capture_sku_reveal_spike.py:无颜色/尺码/坐标/手势参数,只在精确 COLOR_SELECTED_SIZE_HIDDEN 前置成立后沿已取证列间安全通道发出一次固定手势;发出前封存,RPC 正常/超时/异常后均只读调和且绝不重试。后置必须颜色仍选中、摘要仍请选择尺码、S/M 可见且全部 selected=false、未触发提交区;成功后不 Back,由人核对 before/after 后才允许固化生产 reveal 判据。T-103 继续 Doing。
|
||||
|
||||
### 2026-08-05T04:33:56Z · ila
|
||||
|
||||
2026-08-05 T-103 reveal 离线实现与终审通过:新增独立 `capture_sku_reveal_spike.py`,只在拼多多 8.17.0 / PKG110 / Android 16 / 1080×2376、goods_id `937122477375`、完整 `COLOR_SELECTED_SIZE_HIDDEN` 前置两次重证后,封存并发出唯一一次固定 reveal 手势;RPC 正常或结果不明均只读调和且绝不重试。后置要求目标颜色保持精确 selected、S/M 均可见且 selected=false、摘要仍为“请选择:尺码”、提交硬拒绝区仍在面板下方;成功不 Back,等待人工核对。四态 classifier 已绑定 A/B/S/M 真机证据的完整父链与 exact selected set;未知 clickable 祖先、窄浮层、SystemUI 同 bounds、坏 bounds 均失败关闭。独立终审 PASS;聚焦 55/55、client 全量 255/255、compileall、agent-context、diff-check 全部 PASS。未引用数量、确认页、围栏、提交或支付能力。`needs_device=true`,T-103 保持 Doing,等待项目所有者从拼多多首页、仅 Wi-Fi ADB、使用全新 output-dir 执行唯一一次真机 reveal 并人工确认 before/after。
|
||||
|
||||
### 2026-08-05T04:45:40Z · ila
|
||||
|
||||
2026-08-05 T-011 方案 B 由 T-103 所有者就地完成 pdd/device 测试提速:仅修改测试。`test_sku_selection` 保持 47 项,无删除/合并/skip,使用生产既有时钟注入缝和 runner 测试工厂,把 4.029s 降至 1.725s(≤2s);`test_sku_evidence_sanitizer` 保持 25 项,确认根因不是时钟而是重复编码 1080×2376 合成 PNG,改为按尺寸缓存同一确定性 PNG bytes,把 5.383s 降至 4.360s。两模块合计 72/72 PASS;与 T-304 所有者的 polling 改动合并后 client 全量仍为 255/255,12.892s(≤15s),compileall/diff-check/agent-context 均通过。未修改生产代码或任何规格/价格/提交安全断言。T-103 仍因真机 reveal 与最终完整链路人工验收保持 Doing。
|
||||
|
||||
### 2026-08-05T04:56:30Z · ila
|
||||
|
||||
2026-08-05 T-103 reveal 放行前独立审计返修:实现未发现生产缺陷,但原测试未永久覆盖验收条目中的四类后置失败。仅修改 client/tests/pdd/test_sku_reveal_spike.py,新增端到端反例:swipe 后页面不变/尺码仍隐藏、目标颜色 selected 漂移、提交硬拒绝区文本缺失/重复/上移、RPC 结果不明且页面不变。每例均断言 swipe 恰好一次且不重试、target/manifest 不发布、staging 无残留。受托 agent 聚焦 59/59 PASS;主 agent 独立逐行审查并复跑 client 全量 259/259(13.883s)、compileall、agent-context、diff-check 全部 PASS。未改生产代码,未引入数量、确认页、围栏、提交或支付能力。T-103 继续保持 Doing;可在该修复提交推送后放行项目所有者执行唯一一次真机 reveal。
|
||||
|
||||
### 2026-08-05T06:19:03Z · ila
|
||||
|
||||
2026-08-05 T-103 第二次真机失败诊断:项目所有者确认从拼多多首页运行后已进入目标商品并弹出规格面板,但面板保留此前人工选择的“白色 + 其他尺码”,脚本没有选择“黑色CHA(纯棉)”或目标尺码。按调用链,入口后置只接受 PANEL_OPEN_EMPTY;非目标规格使 `sku_entry_panel_verify` 失败,尚未进入 `select_sku_options`,因此 zero target-color click / zero reveal swipe / zero submit。为避免统一错误再次丢失控制流事实,仅修改 capture_sku_reveal_spike.py 复用既有 safe_failure_stage,输出白名单 stage 或 unknown;异常正文、页面文本、serial、XML、路径仍不回显,伪造 stage 无正式 marker 时降级 unknown。独立审计与主审均确认未改 Capturer/Flow/判据/动作;聚焦 60/60、client 全量 260/260(14.788s)、compileall、agent-context、diff-check PASS。T-103 继续 Doing,下一步先由人把该商品规格面板恢复为颜色/尺码均未选,再从首页执行全新 output-dir。
|
||||
|
||||
### 2026-08-05T06:50:08Z · ila
|
||||
|
||||
2026-08-05 T-103 同版本单价格节点变体真机证据与实施方案(先证据、后判据):第一次正式 reveal 在 `sku_entry_panel_verify` fail closed。项目所有者已确认 force-stop 后颜色/尺码恢复未选;root 只读检查证明 PDD 8.17.0/PKG110/Android16/1080×2376/前台正确、summary=`请选择: 颜色分类 尺码`、selected-count=0、目标颜色 bounds `[372,1188][684,1587]` selected=false。证据目录 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-panel-empty-current-only-20260805-143741`,screenshot SHA-256 `7f3143d7fab42b41a088e27a4b2d9f8dbdf31b5ecc9ba02b148ca021304026f6`,XML SHA-256 `d3dbf217b4567069feccb30ab43124c5fa7da3734659ca5452b98c2e0a7ff158`。根因不是 selected,而是价格行由旧双节点变为 exact 单节点:current 仍为 `限1件 ¥12.88 ` `[396,580][675,647]`,其唯一 LinearLayout 为 `[396,575][693,647]` 且仅一 child,外层 FrameLayout `[396,575][1053,647]`;`券前¥29.88` count=0。项目所有者随后在同一 goods_id 937122477375 面板人工选择目标颜色并明确完成第二态确认;目录 `...\sku-panel-color-only-current-only-20260805-144550`,screenshot `931dc2d31f4e34872fdd97879abd076e414b6494569e8a587353266011610545`,XML `c686737942e5fdcebc76c2bc1d5cd9ae90a45f4a921297119ddaa4313758a034`;summary=`请选择: 尺码`,目标颜色 selected=true,未选尺码,current count=1/original count=0,固定 reveal 通道用现有代码只读验证 PASS,未发送 swipe。实施只允许新增两个 exact 单价格前置 spec(PANEL_OPEN_EMPTY / COLOR_SELECTED_SIZE_HIDDEN),旧双价格 spec 继续保留;不得把 original 泛化为 optional、不得接受任意 row/bounds/child count/price text,也不得修改 rolled candidate、数量、确认页、提交或支付。正式再跑前新增带正式 marker 的 reveal 白名单阶段码,至少能明确 `reveal_not_attempted` 与 `reveal_attempted/candidate/after/publish` 边界,异常正文和页面内容仍不外泄;补 cross-variant/duplicate/unknown/伪造 stage 失败测试。T-103 继续 Doing,先提交此证据计划,再实现并独立主审。
|
||||
|
||||
### 2026-08-05T07:19:02Z · ila
|
||||
|
||||
2026-08-05 第二态由用户人工确认:目标颜色“黑色 CHA(纯棉)”已选,尺码选项已显示且尚未选尺码。实现已按两组新 raw 证据收紧:新增 PANEL_OPEN_EMPTY / COLOR_SELECTED_SIZE_HIDDEN 的 exact current-only 价格布局;历史 dual 布局继续按已取证嵌套后代关系匹配;颜色点击后的后置条件绑定点击前完整 price_layout,禁止 dual 与 current-only 双向漂移。reveal 失败阶段新增不可伪造 marker,precondition 保证零 swipe,attempted 及以后仅在唯一 RPC 机会已封存后报告。两名独立 agent 审计均 PASS;根代理直接读取六组真实 raw 验证唯一命中:dual empty/color/S/M 与 current-only empty/color 均正确,M 读价 12.88。根代理完整门禁:client unittest 269/269 PASS,compileall PASS,validate_agent_context PASS,git diff --check PASS。未新增数量、确认页、围栏、提交订单或付款能力。T-103 仍保持 DOING,下一步仅允许强停重置后运行一次真机 reveal,并等待人工验收。
|
||||
|
||||
### 2026-08-05T07:23:56Z · ila
|
||||
|
||||
2026-08-05 15:23(Asia/Shanghai)唯一一次真机 reveal 运行结果:从拼多多 MainFrameActivity 首页、显式 Wi-Fi serial 192.168.0.173:5555、全新 output-dir `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-reveal-current-only-20260805-152300` 启动;CLI 返回 `stage=reveal_precondition`,exit 1,未发布目录。按正式 marker,reveal swipe 未发生;禁止自动重跑。随后准备以 `capture_device_baseline.py` 对保持不动的页面做只读取证,但 `adb devices` 同时出现 Wi-Fi `192.168.0.173:5555` 与 USB `3B65BD02H7F00000`,基线脚本按同一物理手机多通道门禁拒绝,未采集。等待项目所有者拔掉 USB,仅保留 Wi-Fi;手机保持失败后的当前页面。之后只做一次只读截图/XML,不重跑 reveal,再根据首个真实漂移决定最小修复。T-103 保持 DOING。
|
||||
|
||||
### 2026-08-05T07:29:22Z · ila
|
||||
|
||||
2026-08-05 15:26-15:27(Asia/Shanghai)T-103 唯一真实 reveal 动作与失败后只读取证:仅 Wi-Fi ADB,拼多多 MainFrameActivity 首页,显式 goods_id 937122477375,全新 output-dir `...\sku-reveal-current-only-20260805-152636`。App 版本在本次运行重新检查为 8.17.0。脚本在 47 秒后返回 `stage=reveal_candidate`,证明唯一 reveal RPC 已发出;未发布 staging,未重试。手机保持不动后用只读 baseline 采集到 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\reveal-candidate-failure-20260805-152636`:screenshot SHA-256 `5c86a922b1064c1a3bf175182340703ad0a78c6f96d9ccee25ffbee336afdf68`,XML SHA-256 `840ef3e59aa3f19c5c9521d92ff5af769c9e19d348a68b79cce74e83e5f38295`。只读代码诊断 `_candidate_projection` 的首个错误为“候选价格行不唯一”。新 raw 显示目标颜色 exact selected=true,摘要 `请选择: 尺码`,S/M 可见且 selected=false,提交文字仍在 `[285,2225][795,2284]` 的硬拒绝区;滚动态价格是独立 exact current-only 变体:Frame `[396,498][1053,570]` 唯一 child,LinearLayout `[396,498][693,570]` 唯一 child,current=`限1件 ¥12.88 ` `[396,503][675,570]`,`¥29.88` count=0。旧 rolled dual 证据保持不变,不得把 original 泛化为 optional。等待项目所有者对当前手机画面确认:目标黑色仍选、S/M 均可见且均未选、仍在规格面板而非确认页。确认前不改判据、不再操作手机;T-103 保持 DOING。
|
||||
|
||||
### 2026-08-05T07:31:25Z · ila
|
||||
|
||||
2026-08-05 项目所有者完成人工确认:真实 reveal 后目标颜色已选中,未选中任何尺码。结合失败后保持不动采集的截图/XML,S/M 已显示,页面仍是规格面板而非订单确认页;因此唯一固定 reveal 动作已证明不会误选尺码,也未触发提交区。实施方案收紧为:只在独立 spike 的 rolled candidate 中新增与 raw 完全一致的 current-only 价格布局(Frame `[396,498][1053,570]` → 唯一 row `[396,498][693,570]` → 唯一 current `限1件 ¥12.88 ` `[396,503][675,570]`,原价 count=0);旧 dual candidate `快卖完 ¥12.88` + `¥29.88` 原样保留。两种布局必须分别完整唯一匹配,禁止 optional original、跨布局拼装、未知文字/坐标/父链/child count;候选其余颜色、摘要、S/M 未选、提交硬拒绝区判据不变。补 dual/current-only 双向混合、duplicate、unknown、parent drift 失败测试;不得改生产 Flow 的 reveal,不得新增数量、确认页、围栏、提交或支付能力。先提交本计划投影,再由唯一 client 写入者实现、独立审计、根代理重跑真实 raw 与完整客户端门禁。T-103 保持 DOING;代码通过后是否需要再次运行 spike 以形成原子 before/after 发布证据,仍以任务验收全集为准。
|
||||
|
||||
### 2026-08-05T07:53:16Z · ila
|
||||
|
||||
2026-08-05 T-103 rolled current-only candidate 实现与双轮审计完成。唯一 client 写入者只修改 `sku_reveal_spike.py` 与对应测试:candidate 现在绑定两份真实 raw 共有的 exact header→Frame→Relative→ViewGroup→price Frame 链、各层 child count、summary direct parent;旧 dual row/current/original 与新 current-only row/current 是完整互斥 variant,原价绝非 optional,双向 complete/partial/hybrid/duplicate/unknown/parent drift 和已知 `¥29.88`/`券前¥29.88` 残片全部拒绝。首轮审计发现并退回两个 P1(current-only 任意 descendant、dual+current 共存仍通过);返修后根代理及独立审计均复现为 REJECT。真实 current-only after raw projection PASS(14 roles);历史 dual M raw 仅内存机械恢复未选尺码态后 PASS(14 roles)。截图后 current-only→dual 漂移永久测试证明 `stage=reveal_after`、唯一 swipe=1、target/staging 均不发布。根代理完整门禁:client unittest 274/274 PASS,compileall PASS,agent-context PASS,git diff-check PASS;生产新增行只有 clickability 只读检查,无新 tap/swipe/数量/确认页/授权/围栏/提交或支付能力。T-103 仍保持 DOING;下一步需按验收全集形成可发布的原子 before/after spike 证据并由人终验,代码提交后不得复用旧 output-dir。
|
||||
|
||||
### 2026-08-05T07:56:33Z · ila
|
||||
|
||||
2026-08-05 15:55(Asia/Shanghai)T-103 修复后原子 reveal 证据成功发布。前置:仅 Wi-Fi ADB、从拼多多 MainFrameActivity 首页、force-stop 清除持久选择、脚本本次重新核验 PDD 8.17.0 / PKG110 / Android 16 / 1080×2376。命令使用全新目录 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-reveal-accepted-20260805-155502`,33.5 秒完成;manifest `rpc_outcome=completed`、`reveal_attempts=1`、`candidate_status=human_review_required`。哈希:before screenshot `01d3c5a3c39635389d5ff5ec86809fd10dae897d8bc39c6aa5840da8b25f6e60`,before XML `8e318edf578e404d1e2de317d3c206cb7c481378e0b6b60927047d033b867adc`,after screenshot `fa3024167aaf7d5719b0916de8f22c27b83a0b200bc38c750af410ed85cac7d1`,after XML `539e76856c175a973f81a0e879b8d5c4103767a7464858f7a0977d6ffa47ce0f`。根代理只读重证:before exact COLOR_SELECTED_SIZE_HIDDEN + 固定安全通道 PASS;after candidate projection 14 roles PASS,目标颜色 selected 子树唯一,S 与 M 均可见且 selected=false,未发布外部证据/未 Back。截图显示仍为规格面板,提交硬拒绝区在底部;等待项目所有者查看手机/after screenshot 后最终人工确认“目标颜色保持选中、S/M 均未选、未进入订单确认页”。确认前 T-103 保持 DOING,不推进生产 reveal 或后续规格点击。
|
||||
|
||||
### 2026-08-05T08:09:05Z · ila
|
||||
|
||||
2026-08-05 项目所有者对第二次原子 reveal 证据完成三项人工终验并明确回复“是的,3个确认”:1) 目标黑色保持选中;2) 未选中任何尺码;3) 仍在规格面板,未进入订单确认页。证据目录 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-reveal-human-review-20260805-160655`;manifest 为 Wi-Fi / PDD 8.17.0 / PKG110 / Android 16 / goods_id 937122477375 / `reveal_attempts=1` / `rpc_outcome=completed`。哈希:before screenshot `7f1fb74ce91d67054deb462a02f4306128889ec7d04194ba2fb07bb90b664169`,before XML `314257333a50cd3be3a9e883d5a33e5b7da763224210f350b95c6593be7cd710`,after screenshot `e9b628821d1e39b8b501efd21a88e43a5f8e6eda9332c44159c38b0a8a5eb38a`,after XML `e17e0a789f53b33fa564b678511ba633bfb969861fc32417b81e5545e309e9a1`。根代理机器重证 before color-only+safe-path PASS,after projection 14 roles PASS,S/M selected=false。reveal 动作证据门禁现已满足。T-103 仍保持 DOING,因为验收全集还要求将已证固定 reveal 收入生产 Flow,并由人完成入口→目标颜色→固定 reveal→目标 M→读取 12.88→一次安全 Back 的最终真机闭环;不得把本次三项确认误当成整个 T-103 DONE。下一步先做只读设计审计与方案记录,再编码;不新增数量、确认页、提交或支付能力。
|
||||
|
||||
### 2026-08-05T08:20:30Z · ila
|
||||
|
||||
2026-08-05 16:14(Asia/Shanghai)T-103 目标 M 真实后置证据与生产 Flow 实施计划:
|
||||
|
||||
项目所有者在原子 reveal 人审通过后的当前规格面板中,手工点击了 exact M(M(建议100-115))。root 随即只读采集全新证据目录 C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-target-m-current-only-20260805-161434;manifest 绑定 Wi-Fi / 拼多多 8.17.0 / PKG110 / Android 16 / goods_id 937122477375 / 1080×2376。screenshot SHA-256=74e006b25c8618f0b283ff2472ef331e9628306877088b760c103f15f629e1e7,XML SHA-256=6887322d2ba9309e82442aa57b9a4ac4540f2850f6931a2631edbf899138aad1。现有代码只读重证:唯一命中 TARGETS_SELECTED / _DualPriceLayout;仅 exact M selected=true,目标颜色保持 selected=true;_unit_price 精确返回十进制字符串 12.88。截图显示仍为规格面板。
|
||||
|
||||
该证据补齐的唯一获准真实转移是:CURRENT_ONLY COLOR_SELECTED_SIZE_HIDDEN → 固定无参 reveal 一次 → CURRENT_ONLY SIZE_VISIBLE_UNSELECTED(S/M 均 false)→ exact M 一次 → 既有 DUAL TARGETS_SELECTED → 既有价格节点读取 12.88。不得泛化为任意跨 variant;DUAL color-only 在 reveal 前必须零动作失败;不得新增 current-only TARGETS_SELECTED、不得把原价改为 optional、不得修改既有 target classifier 或读价语义。
|
||||
|
||||
生产化方案经两名只读 agent 独立审计后由 root 收敛:
|
||||
1. sku_selection.py 成为 profile/revealed-unselected 判据唯一权威,新增 current-only 可见未选尺码窄状态和显式转移白名单;从 spike 提取共用纯判据,生产模块不得 import spike。
|
||||
2. Protocol 只新增无参数 reveal_size_options_once;绝不公开 swipe/scroll/坐标。Flow 在 RPC 前封存 pending/attempt,固定 reveal 最多一次;fresh hierarchy 连续两帧稳定证明 target color=true、summary=请选择尺码、S/M exact 可见且均 false、硬拒绝区未漂移后,才允许 exact M 一次。M 后继续复用既有 _require_target_panel 与 _unit_price。
|
||||
3. sku_selection_runner.py 的 adapter 固定唯一 reveal 手势并在 RPC 前封存。RPC timeout/结果不明只读调和,resolved 或 unresolved 均不重发;无法证明后置时进入终止态,不得继续 M、读价、Back 或发布。正常完整链才允许 Back 一次。
|
||||
4. sku_reveal_spike.py 删除重复手势/候选判据并单向复用生产 matcher;改造后不得因 Flow 已生产化而重复 reveal。
|
||||
5. T-103 退出证据只记录 back_attempts/RPC outcome 和 post_exit_status=human_review_required;“面板消失”不得自动写 safe_exit=completed。同 goods 商品页自动 post-exit 判据仍留给 T-104。
|
||||
6. 补正反例:dual color-only 零 reveal、variant/hybrid/父链/child-count/summary/selected/硬拒绝区漂移、exact M 缺失/重复/禁用/近似/已有其他尺码、reveal/M delivered-timeout 与 undelivered-timeout、重入二次调用零 RPC、截图后漂移、Back 不明、原子发布竞态、静态 API/AST 不可达数量/确认页/围栏/提交/支付。
|
||||
|
||||
实现仅限 T-103 write_paths,任务继续 DOING。代码与完整 client 门禁通过后,必须再从首页、单 Wi-Fi、全新 output-dir 跑一次生产 runner,并由项目所有者人工核对:入口/颜色/reveal/M 各最多一次、仅 M 选中、规格面板价 12.88、Back 一次后仍为同 goods 商品详情页且未进入确认/提交/支付页。
|
||||
|
||||
### 2026-08-05T08:55:34Z · ila
|
||||
|
||||
2026-08-05 T-103 生产 Flow 离线实现与双重终审完成,提交 60cf05b 已推送 main。
|
||||
|
||||
唯一 client 写入者仅修改 5 个 T-103 文件:sku_selection.py、sku_selection_runner.py、sku_reveal_spike.py 及两份对应测试;未操作真机,未修改共享文档或用户文件。
|
||||
|
||||
实现结果:
|
||||
- 生产只放行同一 Flow 的 current-only empty → exact 目标颜色一次 → 固定无参 reveal 一次 → 连续两帧稳定的 current-only S/M 均未选 → exact M 一次 → 复用既有 dual TARGETS_SELECTED / 12.88。dual color-only、非本 Flow 已选颜色和 fresh revealed-unselected 均在后续 mutation 前零动作失败;已验 S=true→exact M 恢复路径保留。
|
||||
- sku_selection.py 成为 safe path/revealed-unselected 唯一判据;spike 删除重复手势和 300+ 行候选 matcher,单向复用生产判据与正式 adapter,且不调用完整选择流程,避免双 reveal/M。
|
||||
- reveal 手势只有一份不可变 tuple;Protocol 仅新增无参数命名动作,不暴露 swipe/scroll/坐标。entry/option/reveal/Back 均在 RPC 前封存,结果不明只读调和;Flow terminal 防重入,失败 finally 不再自动 Back。
|
||||
- reveal 后提交硬拒绝区要求全局所有 PDD TextView 中包含“提交订单”的集合恰好等于唯一 exact 底部证据节点;额外、上移、改文案、混合价格/父链/child-count/selected 漂移均失败关闭。
|
||||
- 每个 S/reveal/M mutation 前 fresh 重查版本、前台和完整 hierarchy。截图后重证目标规格/12.88;Back 前再用 fresh tree 通过既有 dual TARGETS_SELECTED 与 _unit_price,才封存并发送一次 Back。
|
||||
- 发布前强制 entry/color/reveal/M/Back 各 exactly 1 且 RPC outcome=completed。manifest 移除 safe_exit=completed,记录五个命名 actions 的 attempts/outcome,以及 post_exit_status=human_review_required;T-104 前不自动宣称同商品安全退出。
|
||||
- 调用链未引入数量、确认页、授权、围栏、提交订单或支付能力。
|
||||
|
||||
审查与验证:
|
||||
- 实现 agent 首轮后,独立审计先后发现并退回:S→M 回归、手势双常量、动作审计不足、提交硬拒绝全局唯一缺口;root 另退回 Back 前 target/price 不够精确、mutation 前台未 fresh 重证、manifest 缺五类动作明细。全部补测并修复。
|
||||
- 独立终审最终 PASS,无 P1/P2;额外注入上移/改文案“提交订单”节点均 REJECT。
|
||||
- root 使用 client/.venv 独立复跑:聚焦 84/84 PASS;client 全量 284/284 PASS,13.434 秒(≤15 秒);compileall(src/tests/scripts) PASS;wheel build 与 requirements metadata PASS;validate_agent_context PASS;git diff-check PASS。测试提速只缓存不可变的同一白色 PNG Base64,未删/合并/skip 测试。
|
||||
|
||||
T-103 仍保持 DOING。下一步必须从拼多多首页、仅 Wi-Fi ADB、全新 output-dir 运行生产 run_t103_sku_selection.py,并由项目所有者人工核对:目标颜色与 M、规格面板价 12.88、五类动作均一次、Back 后仍为 goods_id 937122477375 商品详情且未进入确认/提交/支付页。
|
||||
|
||||
### 2026-08-05T08:59:04Z · ila
|
||||
|
||||
2026-08-05 16:57(Asia/Shanghai)T-103 生产 runner 最终真机闭环已单次执行成功,等待项目所有者最终人工确认;任务继续 DOING。
|
||||
|
||||
运行前只读门禁:adb devices 仅有 Wi-Fi serial 192.168.0.173:5555;本次重新读取拼多多 versionName=8.17.0;force-stop 清除手工 M 状态后显式启动 MainFrameActivity,最终前台为 HomeActivity。随后从首页以 canonical goods_id 937122477375、任务颜色“黑色CHA(纯棉)”、尺码“M(建议100-115)”、全新目录执行 production run_t103_sku_selection.py,一次运行 53.9 秒、exit 0;未重跑。
|
||||
|
||||
生产证据目录:C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-selection-production-20260805-final。runner 返回目标规格正确、规格面板单价 12.88。manifest 的五类 actions 全部为 attempts=1 / rpc_outcome=completed:sku_entry、target_color、size_reveal、target_size、back;channel=wifi、PDD 8.17.0、PKG110、Android16、unit_price=12.88、post_exit_status=human_review_required。Back 前 screenshot SHA-256=c0f6a4225c1a59195672ea6ff45a360f33886571c9566b6b6b66c4d7a59abb53,复算一致。root 只读查看该截图:规格面板仍在,已选摘要显示目标黑色与 M,M 有唯一红色选中框,顶部规格面板现价 12.88;底部“提交订单 ¥12.88”仅可见,未点击。
|
||||
|
||||
Back 后不再进行任何 mutation,只用 baseline 只读采集:C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-selection-after-back-20260805-final。前台为 com.xunmeng.pinduoduo/.activity.NewPageActivity;screenshot SHA-256=477fdf4202a6cef9d615bd37defd473b0e18fc6312469b1061b45fb8c00034f4,XML SHA-256=1aefe2b73ec27aa32c00b601c74f208b69675c8807ac91ce28e6997a75624da2,均与 manifest 一致。root 只读查看截图:已返回商品详情页,商品主图/标题与 goods_id 937122477375 证据一致,底部仍是“快要抢光 ¥12.88 / 免拼购买”,没有进入订单确认、提交结果或支付页。
|
||||
|
||||
按 needs_device=true 规则,以上 agent/机器检查不能替代人验收。等待项目所有者在手机或两张本地截图上明确确认三项:1) Back 前目标颜色与 M 正确且规格面板价为 12.88;2) manifest 五类动作各一次;3) Back 后仍是该商品详情页,未进入确认/提交/支付页。确认前不得将 T-103 标 DONE。
|
||||
|
||||
### 2026-08-05T09:00:34Z · ila
|
||||
|
||||
2026-08-05 项目所有者完成 T-103 最终人工真机验收并明确回复“三项确认”:
|
||||
|
||||
1. Back 前目标颜色与 M 正确,规格面板当前价为 12.88;
|
||||
2. manifest 中 sku_entry、target_color、size_reveal、target_size、back 五类动作均为 attempts=1 / rpc_outcome=completed;
|
||||
3. Back 后仍为 goods_id 937122477375 商品详情页,未进入订单确认、提交结果或支付页。
|
||||
|
||||
至此 T-103 的验收要点全集全部满足:已取证页面判据、离线 284/284 与构建/元数据/上下文门禁、生产单次真机闭环和人类最终确认均具备。系统未设置数量、未进入确认页、未提交订单、未创建待付款订单、未触及任何付款能力。现将线上 done 与本地 git status 同步为 DONE;T-104 依赖解除。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 任何规格面板入口、维度容器、选项、选中态和价格判据都必须来自本项目拼多多 8.17.0 真机证据;
|
||||
T-102 详情页证据只能证明已到目标商品,不能证明规格面板结构。T-103 只使用已由人确认无实际地址/
|
||||
手机号的 v5 派生 XML 提取最小 fixture;派生截图的上传适用性不再是本任务的代码门禁。
|
||||
T-102 详情页证据只能证明已到目标商品,不能证明规格面板结构。旧 v5 派生 XML 只能作为历史证据,
|
||||
不得覆盖 2026-08-05 已确认的未选初态、仅目标颜色态、S 非目标态与恢复 M 态。只允许从这五组本机
|
||||
证据提取不含地址、手机号或支付凭据的最小 fixture;原始截图和完整 XML 不提交 Git。
|
||||
- `SkuSelectionFlow` 只允许 T-110 批准的 `open_sku_panel()` 窄能力点击证据/版本绑定、精确唯一的
|
||||
`快要抢光`;当前事实仅覆盖 goods_id `937122477375`、拼多多 `8.17.0`。不得把“免拼购买 /
|
||||
单独购买 / 直接拼成”等其他文案加入包含、前缀、同义或坐标兜底,分别取证前一律拒绝。
|
||||
- 规格选项只能在已确认的面板及对应维度容器内按文本精确唯一匹配;缺失、重复、禁用、维度不明、
|
||||
选中态无法唯一读回或页面版本不符均 fail closed。不得前缀、包含、模糊、相似或跨维度匹配,
|
||||
不得用 OCR / 坐标兜底猜选项。
|
||||
- 单价只允许从规格面板证据确认的节点读取,并以十进制字符串表达;不得使用浮点,不得从详情页、
|
||||
搜索卡片或其他页面的数字补值。券后价、原价、区间价、货币符号拆分或多个候选的语义无法唯一
|
||||
证明时必须返回 unreadable 并转人工。
|
||||
底部购买区第一行 `快要抢光 + 金额` 文本叶节点中心;当前事实仅覆盖 goods_id `937122477375`、
|
||||
拼多多 `8.17.0` 与 1080×2376。该叶节点必须是证据绑定底部唯一可点击 PDD 容器的直接子节点;
|
||||
商品内容区同名小字不得作为入口。金额后缀只参与入口结构识别,不得返回、解析或用作任一道价格
|
||||
闸门的价格来源。
|
||||
- 底部第二行 `免拼购买` 只允许作为证据绑定、不可点击的精确兄弟节点证明当前结构;不得把它或
|
||||
`单独购买 / 直接拼成` 等其他文案加入选择器、点击目标、包含/前缀/同义或坐标兜底。`免拼购买`
|
||||
缺失、漂移、变为可点击或出现在其他结构时一律零点击失败关闭。
|
||||
- 面板状态必须按完整证据 profile 匹配:未选初态、仅目标颜色且尺码隐藏态、目标颜色 + S 态、
|
||||
目标颜色 + M 态不得跨状态拼装价格、摘要、容器、节点类型或坐标。真实初态不得要求尺码 option
|
||||
已经可见,也不得要求任一维度已有唯一 selected。
|
||||
- 规格选项只能在已确认的面板及对应维度容器内按文本精确唯一匹配;颜色 action 是精确
|
||||
content-desc 的可点击 ViewGroup,当前 S/M 尺码 action 是精确文本的可点击 TextView。缺失、重复、
|
||||
禁用、维度不明、选中态无法唯一读回或页面版本不符均 fail closed。不得前缀、包含、模糊、相似、
|
||||
跨维度或 OCR 匹配,也不得用裸坐标猜选项。
|
||||
- 显示尺码只允许私有命名的 `reveal_size_options()`:仅从目标颜色已选、尺码未选且隐藏的已证明状态
|
||||
执行,绑定已取证的嵌套滚动容器与列间安全通道,最多一次且不重试。生产动作落地前必须先取得
|
||||
“reveal 前后 M 均未选、尺码由隐藏变为可见、提交区未触发”的真机证据;不得暴露通用
|
||||
`swipe` / `scroll` / 任意坐标能力。
|
||||
- 生产 reveal 落地前只允许独立的 `capture_sku_reveal_spike.py` 证据脚本执行该固定无参动作:脚本必须
|
||||
自动走受控商品/面板/目标颜色前置,严格命中仅颜色态后才发出一次固定手势;RPC 正常或不明后都只读
|
||||
调和,M 已选、尺码仍隐藏、摘要/颜色/版本漂移或提交区风险均不得发布。成功后不 Back,等待人核对。
|
||||
- 单价只允许在目标颜色与目标 M 均精确读回后,从滚动态规格面板证据确认的只读当前价节点读取,
|
||||
并以十进制字符串表达;不得使用浮点,不得从未选初态、详情页、搜索卡片、底部购买/提交按钮或
|
||||
其他页面的数字补值。未知促销前缀、券后价、原价、区间价、货币符号拆分、可点击祖先或多个候选
|
||||
无法唯一证明时必须返回 unreadable 并转人工。
|
||||
- T-103 代码路径不得引用或实现 `set_quantity_and_readback()`、`go_to_order_confirm()`、
|
||||
`create_submission_fence()`、`submit_order_once()`、
|
||||
通用 `click`、创建订单、授权、提交围栏或任何支付能力;不得进入订单确认页,不得创建待付款订单。
|
||||
公开通用 `click` / `swipe` / `scroll`、创建订单、授权、提交围栏或任何支付能力;不得进入订单确认页,
|
||||
不得创建待付款订单。
|
||||
规格面板中的“提交订单”、微信支付、先用后付、0 元下单只可作为硬拒绝判据,不能返回可点击对象。
|
||||
- 不把 `/mnt/d/chengma/cmroubao`、`/mnt/d/chengma/cmpdd` 或任何旧版本的节点、Activity、选择器、
|
||||
坐标、文本形态和页面结论当作事实;前序项目只可用于理解为什么必须 fail closed。
|
||||
@@ -210,5 +427,7 @@ T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space
|
||||
- T-103 不再扩展截图遮罩器;本任务先以本机原始截图完成真机验收。生产单趟原始截图上传由 T-204
|
||||
接入,允许截图包含页面已显示的地址和手机号,但只允许已认证设备上传、已登录管理员查看;完整 XML、
|
||||
外部支付页和支付凭据不得上传。
|
||||
- 一次 Back 在调用前即封存且不得重试;仅“面板消失”不得自动宣称生产安全退出成功。同一目标商品
|
||||
详情页的严格 post-exit 判据由 T-104 取得独立真机证据后开放。
|
||||
- `needs_device: true`:agent 只能完成离线实现并保持 `DOING`;只有人完成安全入口、精确规格选择、
|
||||
SKU 单价语义、页面对应性与安全退出验收后才能标 `DONE`。HTTP 截图上传实现不属于 T-103 验收。
|
||||
|
||||
+67
-3
@@ -3,10 +3,10 @@ id: T-104
|
||||
title: 验证规格选择能力安全退出
|
||||
phase: 1
|
||||
deps: [T-103]
|
||||
status: TODO
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 32
|
||||
context_ref: 3a0a41d
|
||||
context_ref: 29fb528
|
||||
work_branch: task/t-104-safe-exit
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
@@ -21,7 +21,7 @@ write_paths:
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=32 synced=2026-08-04T15:33:56Z sha256=cd649f78ae8d79edcfe94fb3702f8dbf99b12d1d4cbf2c0069b14cffa8703464 -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=32 synced=2026-08-05T09:57:21Z sha256=8469785af6c7ffd0d42dd7f2bb56bad15996a1f696c854823074304739bf652e -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-103 已实现受控规格选择、闸门一读价、规格面板原始截图和单次 Back,但当前 `exit_sku_panel_safely()` 只要 hierarchy 变化且规格面板判据不再成立就返回成功。现有纯 mock 测试还把 Back 后的空 hierarchy 当成成功并允许 runner 发布 `safe_exit=completed`。2026-08-04 的只读诊断同时证明:通知栏覆盖时 `app_current` 仍可能报告拼多多,而节点树全部属于 SystemUI。因此“面板消失”或“前台包仍是 PDD”都不是安全退出的充分条件;T-104 必须先取得独立 post-exit 真机证据,再把后置条件收紧为稳定回到同一目标商品详情页。
|
||||
@@ -62,6 +62,70 @@ T-103 已实现受控规格选择、闸门一读价、规格面板原始截图
|
||||
### 2026-08-04T13:46:33Z · ila
|
||||
|
||||
2026-08-04 T-306 预研反向约束 T-104:成功 SkuSelectionRunResult 必须显式携带规格面板原始 screenshot_path 与实际 UTC captured_at;只有规格选择、闸门一读价、截图和严格安全退出全成功才返回。T-306 不枚举目录、不读 manifest 猜文件/时间,post-exit 商品详情截图不得冒充 gate1 证据。
|
||||
|
||||
### 2026-08-05T09:04:13Z · ila
|
||||
|
||||
2026-08-05 T-103 已在 git/线上完成,T-104 依赖解除,现由 root 领取并转 DOING;领取基线 29fb528,沿用工作路径 task/t-104-safe-exit。
|
||||
|
||||
先证据后判据的实施顺序保持不变:
|
||||
1. 先只读审计 T-103 最新生产 Flow、最终 Back 前/后本地证据与 T-104 任务边界。T-103 的 sku-selection-production-20260805-final 与 sku-selection-after-back-20260805-final 已获人确认,可用于设计窄取证脚本和寻找候选正锚;但它们不是同一 T-104 原子 manifest,不能直接冒充阶段 A 验收证据。
|
||||
2. 唯一 client 写入者先实现 capture_sku_exit_spike.py:输入只允许显式 serial 与全新 output-dir;无商品 intent、页面 click、坐标、规格动作、数量、确认、提交或支付能力。前置复用 T-103 已证目标颜色/M/12.88 classifier;唯一 mutation 为 RPC 前封存且最多一次的 Android Back。
|
||||
3. Back 后只读连续采集 screenshot/XML/App/版本并原子发布本机证据;manifest 仅记录 back_attempts=1、RPC outcome、哈希和 human_review_required,人工确认前绝不写 safe_exit=completed。结果不明不重试、不发送任何后续 mutation。
|
||||
4. 离线脚本、原子性、超时/重入、静态 API/AST 测试经独立审计和 root 门禁通过后,才由项目所有者把手机手工停在已验目标规格面板并执行阶段 A;人确认同 goods 商品详情、面板关闭、未进入确认/提交/支付且未创建订单。
|
||||
5. 只有上述人验后,才从新 raw 提取 privacy-safe 的 product_exit_8_17_0.xml 最小 fixture,并设计阶段 B。若 raw 不含唯一同商品正锚,保持 DOING,不编造 selector、不放宽为任意 PDD 商品页。
|
||||
6. 阶段 B 收紧现有 Flow/runner:版本/PDD 包、同 goods 正锚、无危险页与连续两帧稳定全部成立才返回安全退出;空 hierarchy/SystemUI/锁屏/其他 PDD 页/面板未退/锚点漂移全部失败。成功结果同时补齐规格面板 screenshot_path 与截图附近 UTC captured_at,post-exit 图不得冒充 Gate1。
|
||||
|
||||
写路径严格限于 T-104 frontmatter;共享 current-state 由 root 合入。needs_device/needs_human_review 保持,agent 不得自行 DONE。
|
||||
|
||||
### 2026-08-05T09:27:14Z · ila
|
||||
|
||||
2026-08-05 阶段 A 离线实现与独立审查完成,代码 commit 43fd8ce 已推送 main。
|
||||
|
||||
仅新增窄取证器/CLI/测试:人工先停驻目标规格面板,内存中连续两次核验 PDD 8.17.0、PKG110/Android 16/1080x2376、目标颜色+M+12.88;唯一 mutation 为 RPC 前封存且最多一次的命名 Android Back。Back 响应不明不重试,只允许发布 ambiguous_reconciled + human_review_required。Back 后在任何截图/XML 前先核验仍为 PDD 8.17.0,采集后复核 package/activity/version 稳定;外部包、版本或页面漂移、PNG/XML/读取/发布失败均清 staging、零 target。
|
||||
|
||||
manifest 不含 safe_exit 或 Gate1 资格;post-exit 图/XML 仅本地人工复核。静态检查证明脚本不可达 intent、页面 click、规格动作、数量、确认、围栏、提交或支付能力。
|
||||
|
||||
root 独立门禁:focused 71/71、client full 293/293、compileall、validate_agent_context、git diff --check、完整 init.ps1 全部通过。独立 reviewer 终审 PASS,无 P1/P2。
|
||||
|
||||
任务保持 DOING,下一步等待项目所有者把手机手工停在 goods_id 937122477375 的目标规格面板(黑色CHA(纯棉)、M(建议100-115)、12.88),执行一次阶段 A 脚本并人工确认 Back 后为同一商品详情、面板关闭、未进入确认/提交/支付、未创建订单。
|
||||
|
||||
### 2026-08-05T09:38:22Z · ila
|
||||
|
||||
2026-08-05 阶段 A 首次真机尝试在唯一 Back 前置核验处 fail closed,未发布 target/staging。项目所有者随后人工确认规格面板仍打开,因此本次 Back 没有送达;后续仅做只读诊断。
|
||||
|
||||
只读诊断在 PDD 8.17.0、PKG110、Android 16、1080x2376、Wi-Fi 设备上证明:目标颜色与 M 均为 selected=true,尺码仍为既有精确 bounds `[439,1582][831,1667]`;同一目标颜色卡片因横向列表滚动,从既有 `[372,1000][684,1024]` 精确移动为 `[126,1000][438,1024]`,其余完整 TARGETS_SELECTED profile 与 12.88 双价格布局均未漂移。
|
||||
|
||||
新本地只读证据:`%LOCALAPPDATA%\cmbuyer\artifacts\T-104\sku-exit-precondition-color-shift-937122477375-20260805`。screenshot SHA-256 `aef1281c09e72990f06ef5a301d701efc37eaeb0f3ed5866f617e81313b22028`;hierarchy SHA-256 `3ffd7fa673e3b83e288c37eecaf65ab03480f9471814dd18019480fdeff0a41e`;captured_at `2026-08-05T09:35:40.312763+00:00`。项目所有者在运行前已按要求声明已选“黑色CHA(纯棉)”与“M(建议100-115)”、价格 12.88 并保持面板打开。
|
||||
|
||||
最小兼容边界:只新增上述第二个 exact color bounds 的完整 TARGETS_SELECTED spec;文字、selected、价格、摘要、尺码和其余结构继续全部精确匹配,任意相邻/未知 bounds 仍在 Back 前零动作失败。真机只读双 precheck 已通过;尚未再次发送 Back。
|
||||
|
||||
### 2026-08-05T09:47:20Z · ila
|
||||
|
||||
2026-08-05 T-104 阶段 A 真机取证完成并通过项目所有者人工复核。
|
||||
|
||||
最终证据目录:`%LOCALAPPDATA%\cmbuyer\artifacts\T-104\sku-exit-phase-a-937122477375-20260805-final-v3`;captured_at `2026-08-05T09:44:33.891598+00:00`;Wi-Fi;PKG110;Android 16;PDD 8.17.0;goods_id `937122477375`。manifest 明确 `back_attempts=1`、`rpc_outcome=completed`、`post_exit_status=human_review_required`,不含 safe_exit 或 Gate1 资格。
|
||||
|
||||
原子产物:post_exit_screenshot.png SHA-256 `2b9d931d8dfa7325629f53a996d65dc2e95bfa185ddeae174110fa1237221e2e`;post_exit_hierarchy.xml SHA-256 `d57e835e9d4b1b7ac1b96d6178ed5d9de09f53b2b64ec2ef76740980fc9243bc`;post_exit_app.json SHA-256 `b86205c3a7a865ce14eb57e4776184fcf7cc8aefe6d5f7442839be6218f6abf5`。
|
||||
|
||||
生产 capturer 外仅包了一层只记录方法名的诊断代理,调用序列证明:两次前置核验 → 唯一一次 pressKey → 前台/版本 → screenshot → XML → 前台/版本;没有 intent、页面 click、规格、数量、确认、提交或支付动作,也没有第二次 Back。
|
||||
|
||||
项目所有者完成三项人工确认:①手机与截图均为 goods_id 937122477375 的同一商品详情;②规格面板已关闭,未进入确认、提交或支付页面;③未创建订单。截图/XML 由同一 staging 原子发布且前后 package/activity/version 稳定。
|
||||
|
||||
阶段 A 达标。任务保持 DOING,下一步只从上述本项目真实 post-exit XML 提取无个人信息/支付凭据的最小 `product_exit_8_17_0.xml`,再实现版本绑定、同商品正锚、连续两帧稳定的阶段 B 判据。
|
||||
|
||||
### 2026-08-05T09:56:55Z · ila
|
||||
|
||||
2026-08-05 T-104 阶段 B 完成,代码 commit `1bb5492` 已推送 main。
|
||||
|
||||
从已人工确认 raw XML 提取最小 `client/tests/pdd/fixtures/product_exit_8_17_0.xml`:同商品正锚为唯一 exact `tv_title` 容器、完整标题 content-desc、两段直接只读文本及固定属性;同时要求已取证底部规格入口完整投影唯一。安全退出不解析或返回商品页价格,规格入口中的金额只保持既有身份常量,Gate1 仍只来自规格面板原始截图。
|
||||
|
||||
`exit_sku_panel_safely()` 现在每帧重查 PDD 8.17.0/前台包,要求同商品投影连续两次一致;单次命中后发生漂移会清空稳定计数。空 hierarchy、面板仍在、SystemUI、锁屏、非 PDD overlay、PDD 其他页、正锚缺失/重复/属性漂移、版本/前台漂移与合成危险动作语义全部失败关闭,所有路径 Back≤1。
|
||||
|
||||
runner 只有在精确规格、Gate1 读价、原始规格 PNG、严格同商品退出和完整动作审计全部成功后才发布 `safe_exit=completed`;`SkuSelectionRunResult` 明确返回原始规格 `screenshot_path` 与截图 RPC 成功附近的 UTC-aware `captured_at`,manifest 使用同一时间。post-exit 图不进入 runner 结果,也无 Gate1 资格。
|
||||
|
||||
验证:真实 post-exit raw 与最小 fixture 均被新投影命中;focused 76/76、client full 298/298、compileall、validate_agent_context、git diff --check、完整 init.ps1 全部通过。静态边界继续证明 T-104 不可达数量、确认、授权、提交围栏、submit_order_once 或支付能力。
|
||||
|
||||
结合阶段 A 的最终 manifest 与项目所有者三项人工确认,T-104 全部验收要点已满足,可转 DONE。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
+67
-11
@@ -3,15 +3,25 @@ id: T-105
|
||||
title: 数量设置、读回与闸门二
|
||||
phase: 1
|
||||
deps: [T-104]
|
||||
status: TODO
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 40
|
||||
context_ref: 8c50e15
|
||||
context_ref: 67b4278
|
||||
work_branch: task/t-105-quantity-gate2
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-105.md
|
||||
- docs/02-requirements.md
|
||||
- docs/04-architecture.md
|
||||
- docs/05-coding-rules.md
|
||||
- docs/api.md
|
||||
- docs/06-tasks.md
|
||||
- docs/07-user-stories.md
|
||||
- docs/08-interaction-checklist.md
|
||||
- docs/routes.md
|
||||
- docs/design/desk-execution.html
|
||||
- docs/design/web-task-detail.html
|
||||
- client/src/cmbuyer_client/pdd/quantity_gate2_spike.py
|
||||
- client/src/cmbuyer_client/pdd/quantity_gate2.py
|
||||
- client/src/cmbuyer_client/pdd/quantity_gate2_runner.py
|
||||
@@ -23,7 +33,7 @@ write_paths:
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=40 synced=2026-08-04T13:59:13Z sha256=86e0079c5db57033818f75763d3e9012764e11f805a1ab12cafbe58a8ba9a526 -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=40 synced=2026-08-06T01:53:44Z sha256=fcc45a75560c145044787020f8e994751c203763df3a4a46d0cdb765958a71f2 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-104 将把规格选择、闸门一读价、原始截图和安全退出固化为受控能力。T-105 先对当前真机数量控件和读回位置取证,再实现规格面板内的数量设置、精确读回与闸门二;不进入确认页、不接触最终提交。
|
||||
@@ -42,8 +52,8 @@ T-104 将把规格选择、闸门一读价、原始截图和安全退出固化
|
||||
2. 阶段二从本项目证据提取最小 fixture,固化数量显示、增减控件、选中规格和价格节点的精确唯一结构;版本/包/面板/规格/控件任一失配都零点击。
|
||||
3. QuantityGate2Flow 接受 T-104 Gate1Observation、目标数量和最高总价。数量必须为服务端任务正整数,不接受浮点、字符串猜测或超上限值。
|
||||
4. 每次数量修改只点击证据证明的唯一控件一次,并等待明确后置条件;超时或结果不明不得重试该次点击。达到目标前每一步都重新定位和读回,不使用裸坐标或缓存节点。
|
||||
5. 闸门二重新读取颜色、尺码、数量和规格面板单价;颜色/尺码必须等于目标,数量精确等于请求,gate2 单价逐字规范化后等于 gate1 单价,并用十进制运算确认单价×数量不超最高总价。
|
||||
6. 成功结果是不可变 Gate2Observation,包含请求/实际规格、quantity_read、gate1/gate2_unit_price、max_total_price、规格面板原始 screenshot_path 与 UTC captured_at。
|
||||
5. 闸门二重新读取颜色、尺码、数量和规格面板目标数量总额;颜色/尺码必须等于目标,数量精确等于请求,`gate2_panel_total_price` 必须唯一可读且以十进制比较不超最高总价。2026-08-06 真机证明多件优惠非线性,数量 1 为 12.88、数量 2 为 32.76,因此不得把 gate1 金额乘数量、不得要求 gate2 等于 gate1,也不得用底部“提交订单”金额作为候选或兜底。后续闸门三必须与该 Gate2 面板总额严格相等并再次不超上限,否则转人工。
|
||||
6. 成功结果是不可变 Gate2Observation,包含请求/实际规格、quantity_read、gate1_unit_price、gate2_panel_total_price、max_total_price、规格面板原始 screenshot_path 与 UTC captured_at;旧 `gate2_unit_price` 语义已被真机证据否定,后续服务端契约与存储必须在 T-210/T-205 前改名迁移,禁止以旧字段名承载总额。
|
||||
7. 失败、价格不可读/多候选、规格漂移、数量不符或超价都安全停止并使用 T-104 一次性安全退出;Back 结果不明不重试。
|
||||
8. 能力与 T-103/T-104 分层:T-103 不能反向导入本任务;本任务不暴露确认页导航、通用 click、最终提交控件、围栏或付款能力。
|
||||
|
||||
@@ -51,9 +61,9 @@ T-104 将把规格选择、闸门一读价、原始截图和安全退出固化
|
||||
|
||||
- 先证据后实现,执行记录包含两态截图/XML/hash、版本、设备、goods_id 与人工确认。
|
||||
- 离线覆盖数量边界、每步单击一次、后置条件、超时不重试、节点缺失/重复、SystemUI/锁屏/版本漂移。
|
||||
- 覆盖闸门二规格不变、价格严格相等、Decimal 总价、数量读回及所有失败分支零确认页动作。
|
||||
- 覆盖闸门二规格不变、数量读回、Gate2 面板总额唯一可读、Decimal 上限比较、非线性优惠反例,以及节点缺失/重复/漂移和所有失败分支零确认页动作;静态禁止把 Gate1 乘数量、底部“提交订单”金额或旧 `gate2_unit_price` 当作 Gate2 事实。
|
||||
- 静态证明 T-103 不导入数量,本任务不可达确认页、围栏、submit_order_once 或付款。
|
||||
- 真机人工确认目标数量、规格、两次价格、截图、一次安全退出,且未进入确认/提交/支付页、未创建订单。
|
||||
- 真机人工确认目标数量、规格、Gate1 单价与 Gate2 面板总额、截图、一次安全退出,且未进入确认/提交/支付页、未创建订单。
|
||||
- client 全量 unittest、compileall、完整 init、上下文校验、Vikunja 导出与 diff-check 通过;agent 保持 DOING 等人工验收。
|
||||
|
||||
## 执行记录
|
||||
@@ -61,6 +71,47 @@ T-104 将把规格选择、闸门一读价、原始截图和安全退出固化
|
||||
### 2026-08-04T13:58:34Z · ila
|
||||
|
||||
2026-08-04 预研定值:T-105 依赖 T-104 的一次安全退出,严格分“数量两态只读取证”和“人工确认后实现”两个门禁;闸门二输出显式 screenshot_path/captured_at,且不可达确认页、提交或付款。
|
||||
|
||||
### 2026-08-05T09:58:46Z · ila
|
||||
|
||||
2026-08-05 T-104 已在 git/线上完成,T-105 依赖解除,现由 root 领取;领取基线 `67b4278`,沿用工作路径 `task/t-105-quantity-gate2`。
|
||||
|
||||
严格按两阶段门禁:
|
||||
1. 先实现只读 `quantity_gate2_spike.py` 与 CLI,只接受显式 serial、goods_id、human-declared state/quantity 和全新 output-dir;核验 PKG110/Android16/PDD8.17.0/PDD 前台/1080x2376 后,仅采 screenshot/XML/App/设备摘要并原子发布。不得 intent、页面 click、坐标、数量修改、Back、确认、提交或支付。
|
||||
2. 项目所有者分别把目标商品规格面板停在黑色CHA(纯棉)+M(建议100-115)+数量1,以及相同规格+数量2;各运行一次只读取证并人工确认控件、读回、价格、规格和页面对应性。
|
||||
3. 只有两态人验后才从本项目 raw 提取最小 fixture,实现逐步单击一次、每步 fresh 定位/readback、Gate2 Decimal 总价和 T-104 一次性安全退出。任一失败不进入确认页。
|
||||
|
||||
写路径严格限于 T-105 frontmatter;共享 current-state 由 root 合入。needs_device/needs_human_review 保持,未完成人工验收前不得 DONE。
|
||||
|
||||
### 2026-08-05T10:08:24Z · ila
|
||||
|
||||
2026-08-05 T-105 阶段一只读取证代码完成并推送,代码 commit `994b6c8`。新增 `quantity_gate2_spike.py` 与 CLI:只接受显式 serial、固定 goods_id 937122477375、人工声明 initial=1 / target=2 和全新目录;每次重新核验 PKG110 / Android 16 / PDD 8.17.0 / PDD 前台 / 1080×2376,只调用 `takeScreenshot` 与 `dumpWindowHierarchy`,保存 screenshot/XML/App 摘要/manifest,前后台或版本漂移不发布。接口与静态测试证明无 intent、click、swipe、Back、数量修改、确认、提交或支付能力。专项 13/13、客户端全量 311/311、compileall、agent-context、完整 `init.ps1` 均通过。下一步必须由人分别准备同一商品、黑色CHA(纯棉)+M(建议100-115) 的数量 1 和数量 2 两态并运行只读取证;两态人工确认前不实现数量点击、readback 或 Gate2 判据。任务保持 DOING。
|
||||
|
||||
### 2026-08-06T01:00:27Z · ila
|
||||
|
||||
2026-08-06 T-105 阶段一两态真机证据与四项人工确认完成。设备 PKG110 / Android16 / WiFi,PDD 8.17.0,goods_id 937122477375,目标规格均为黑色CHA(纯棉)+M(建议100-115)。
|
||||
|
||||
初态目录:`C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-105\quantity-initial-937122477375-20260806-v3`;captured_at `2026-08-06T00:53:00.023935+00:00`;screenshot SHA-256 `959c62f90d05a4d2a8e212b0286d7ad7e958e00e56915ddf733ce7fd9b11f9c0`;XML SHA-256 `f31d15ef0763d9685448aba0086b6f67b459e4991b5d01a5daae333dd3b9c16d`。人工确认数量1、目标规格、金额12.88。
|
||||
|
||||
目标态目录:`C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-105\quantity-target-937122477375-20260806`;captured_at `2026-08-06T00:55:30.898374+00:00`;screenshot SHA-256 `1954d9f8dbbc80b73ef5b93ae941c0501baa3f35c3f040e0cd4ef9723eb40057`;XML SHA-256 `e6ffcdc71119b8e0ac35e135afac54517f9c28c4d9da1070168901dce1988b81`。人工确认数量2、目标规格不变、金额32.76。
|
||||
|
||||
人工四项确认:两态数量分别1/2;颜色尺码均未变化;金额分别12.88/32.76;未进入确认/支付页、未点击“提交订单”、未创建订单。
|
||||
|
||||
XML 允许字段摘要:数量节点为唯一 clickable/enabled EditText,`1 [480,752][561,827]` → `2 [480,750][561,825]`;唯一加号为 clickable/enabled ImageView、content-desc 精确“增加数量”、初态 bounds `[567,752][645,827]`、目标态 `[567,750][645,825]`;顶部价格角色 `快卖完 ¥12.88` → `快卖完 ¥32.76`;底部 `提交订单 ¥...` 继续排除且禁止点击。真机事实证明多件优惠非线性,Gate2 必须读取目标数量面板总额并与最高总价比较,不能继续用旧 gate2_unit_price 等值假设。任务进入阶段二,仍保持 DOING。
|
||||
|
||||
### 2026-08-06T01:04:25Z · ila
|
||||
|
||||
2026-08-06 T-105 初态首次只读取证阻塞取证:人工已确认规格面板为目标颜色/尺码/数量1,并关闭 OPPO 游戏助手视觉层;`dumpsys window` 的 mCurrentFocus/mFocusedApp 与 `dumpsys activity activities` 的唯一 topResumedActivity 均为 `com.xunmeng.pinduoduo/.activity.NewPageActivity`,PDD versionName=8.17.0、PKG110、Android16、1080×2376 均正确,但 uiautomator2 `app_current()` 误报 `com.oplus.games`。根因是当前 adbutils 2.12.0 先执行 `dumpsys window windows`(本机 Android16 无对应焦点行),随后只识别旧式 `mResumedActivity:`,不识别实际 `topResumedActivity=`,最终错误退回 `dumpsys activity top` 的最后一项。
|
||||
|
||||
最小修复计划:不放宽“PDD 必须前台”,也不接受 `com.oplus.games`。T-105 阶段一改用仅限 PKG110/Android16 的窄只读 foreground reader,执行固定的 adb 参数数组:指定 serial、shell、dumpsys activity activities;只接受恰好一个 `topResumedActivity` 且 package 精确为 PDD、activity 非空;缺失/重复/其他包/命令失败全部不截图、不发布。通用 shell 不暴露给 capturer,既有只读设备协议反而移除错误的 `app_current`。离线补齐真实 Android16 行、旧格式拒绝、重复/缺失/其他包/命令失败、前后漂移测试;验证后再重跑数量1初态。任务仍 DOING。
|
||||
|
||||
### 2026-08-06T01:28:33Z · ila
|
||||
|
||||
2026-08-06 T-105 阶段二离线实现已完成并推送,代码 commit `21628a4`。从本项目两态证据提取最小 fixture,生产 Flow 只允许数量 1 零点击或数量 1→2 的唯一加号一次点击;每次 fresh 核验 PKG110 / Android 16 / PDD 8.17.0 / PDD 前台 / 1080×2376、目标颜色尺码、数量读回与顶部面板总额。数量 2 的 Gate2 事实为 `gate2_panel_total_price=32.76`,直接以 Decimal 比较 `max_total_price`;未使用 Gate1×数量推导 Gate2,底部“提交订单”金额不是候选,旧 gate2_unit_price 未进入生产代码。动作 RPC 前封存,超时/结果不明不重试;只有已知精确状态才执行 T-104 一次 Back,同商品详情需连续两帧稳定。模块不可达确认页、围栏、提交或付款。专项 32/32、客户端全量 330/330、compileall、agent-context、diff-check 与完整 init.ps1 均通过。下一步仅需项目所有者把目标面板手工恢复到数量1后运行生产验收;人工确认数量2、目标规格、12.88/32.76、Gate2 原图、一次安全退出,以及未进入确认/提交/支付页、未创建订单。任务保持 DOING。
|
||||
|
||||
### 2026-08-06T01:52:14Z · ila
|
||||
|
||||
2026-08-06 T-105 最终生产真机验收完成。证据目录:`C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-105\quantity-gate2-live-937122477375-20260806`;manifest:同目录 `manifest.json`;captured_at `2026-08-06T01:33:33.818765+00:00`;Gate2 screenshot SHA-256 `6b52c73f933934d270f0c2f5c9e37c67f7fba443ce03340807a764f80a2bf299`。manifest 固定事实:PKG110 / Android 16 / WiFi / PDD 8.17.0 / goods_id 937122477375;数量请求与读回均为2;黑色CHA(纯棉)+M(建议100-115);Gate1 单价12.88;Gate2 面板总额32.76;最高总价40.00;加号1次且 completed;Back 1次且 completed;safe_exit=completed。项目所有者随后明确完成四项人工确认:Gate2 截图显示数量2、目标规格与顶部32.76;Back 后回到同一商品详情且面板关闭;未进入确认/提交/支付页;未创建订单且无异常操作。T-105 全部验收要点满足,达到条件即停止,不追加真机测试。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
@@ -68,15 +119,20 @@ T-104 将把规格选择、闸门一读价、原始截图和安全退出固化
|
||||
- 必须先由人在拼多多 8.17.0 的目标规格面板分别准备初始数量与目标数量状态,只读采集并人工确认
|
||||
screenshot/XML/版本/goods_id/规格/数量的对应性,之后才允许从本项目证据编写数量判据和点击代码。
|
||||
不得从前序项目、旧 XML、控件常识、OCR、裸坐标或推理直接实现。
|
||||
- 数量控件、读回位置、规格选中态和两次价格都必须证据绑定且精确唯一。每次修改数量最多点击已批准
|
||||
- 数量控件、读回位置、规格选中态、Gate1 单价和 Gate2 面板总额都必须证据绑定且精确唯一。每次修改数量最多点击已批准
|
||||
控件一次并等待明确后置条件;超时或结果不明不得重试同一次点击,不得缓存节点跨状态继续操作。
|
||||
- 闸门二必须重新核对颜色/尺码未漂移、数量精确等于任务、gate2 单价严格等于 gate1 单价,并以
|
||||
十进制运算验证单价乘数量不超最高总价。价格只从规格面板读取,不用详情页或其他数字兜底。
|
||||
- 闸门二必须重新核对颜色/尺码未漂移、数量精确等于任务,并从证据绑定的规格面板顶部当前金额
|
||||
角色唯一读取 `gate2_panel_total_price`,以十进制直接验证其不超最高总价。2026-08-06 真机已证明
|
||||
多件优惠非线性:数量 1 为 `12.88`、数量 2 为 `32.76`;不得用 gate1 金额乘数量猜总额、不得要求
|
||||
gate2 等于 gate1,也不得把底部“提交订单”金额作为候选或兜底。后续闸门三必须与 Gate2 面板总额
|
||||
严格相等且再次不超上限;任一不符即转人工。
|
||||
- 只有规格、数量、价格、总价上限和原始截图全部通过后才返回不可变 Gate2Observation;它必须显式
|
||||
携带 `screenshot_path` 与 UTC `captured_at`,不得让后续任务枚举目录或读取 manifest 猜证据。
|
||||
- 任一判据失败、规格漂移、数量不符、价格不可读/多候选/变化或超价都停止前进,只能调用 T-104
|
||||
已验证的一次性安全退出;Back 超时或结果不明不重试,不把“面板消失”当作退出成功。
|
||||
- T-103/T-104 不得反向导入本任务。本任务不进入确认页、不观察或获取最终提交控件,不实现通用 click、
|
||||
事件、证据上传、围栏或结果接口;不编写或引用点击“提交订单”的代码,不编写任何付款能力。
|
||||
- `needs_device: true`:agent 完成离线实现后仍保持 DOING。只有人确认真机数量、规格、两次价格、
|
||||
- 旧 `gate2_unit_price` 字段名已被本项目真机证据否定,不能用旧名字承载总额来掩盖语义变化;API 与
|
||||
服务端存储必须在 T-210/T-205 消费 Gate2 前迁移为 `gate2_panel_total_price`,迁移未完成时不得建立围栏。
|
||||
- `needs_device: true`:agent 完成离线实现后仍保持 DOING。只有人确认真机数量、规格、Gate1 单价与 Gate2 面板总额、
|
||||
截图和安全退出,且未进入确认/提交/支付页、未创建订单,才可完成任务。
|
||||
|
||||
+13
-3
@@ -3,10 +3,10 @@ id: T-106
|
||||
title: 确认页、闸门三与返回路径真机取证
|
||||
phase: 1
|
||||
deps: [T-105]
|
||||
status: TODO
|
||||
status: DOING
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 41
|
||||
context_ref: 0144054
|
||||
context_ref: db2b781
|
||||
work_branch: task/t-106-confirm-evidence
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
@@ -18,7 +18,7 @@ write_paths:
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=41 synced=2026-08-04T14:02:41Z sha256=1b7fda4d3b4951f20504d45b88676fda9271f1aaeaacb67b0674e4a6357ca3cc -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=41 synced=2026-08-06T02:20:32Z sha256=3e585159a2607c63f54d72b84d9779699cce3cc79805ebc7c2bbd08cb80b815f -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-105 验证规格面板数量与闸门二后,下一步会进入更高风险的订单确认页。T-106 只由人手工准备确认页、最终提交控件可见态和一次返回后的页面,再用完全只读脚本采集真机事实;本任务不自动导航、不写确认页判据。
|
||||
@@ -58,6 +58,16 @@ T-105 验证规格面板数量与闸门二后,下一步会进入更高风险
|
||||
### 2026-08-04T14:02:39Z · ila
|
||||
|
||||
2026-08-04 主审补齐导航来源事实:T-106 必须采集四态——闸门二后的确认页导航前状态、确认页闸门三、最终提交控件可见态、人工一次返回后状态;人记录进入确认页时实际单击的精确控件/容器与匹配数,agent 仍只读取证。缺少来源态时 T-107 不得从目标页反推入口。
|
||||
|
||||
### 2026-08-06T02:12:17Z · ila
|
||||
|
||||
2026-08-06T02:12:16.724Z · Codex
|
||||
已领取 T-106。基线 init.ps1 通过(client 330 tests);按任务边界只实现四态纯只读取证,禁止 click/press/back/swipe/input/intent,代码完成后等待人工真机准备与验收。
|
||||
|
||||
### 2026-08-06T02:20:23Z · ila
|
||||
|
||||
2026-08-06T02:20:23.748Z · Codex
|
||||
T-106 四态纯只读取证代码已完成:新增 order_confirm_spike、capture_order_confirm_spike.py 与 16 项专项测试。静态 AST 证明生产模块/CLI 只允许 takeScreenshot、dumpWindowHierarchy,且不可达页面修改、确认页导航、提交、围栏或付款能力。client 全量 346 tests、compileall、validate_agent_context 与完整 init.ps1 均通过。任务保持 DOING,等待项目所有者依次人工准备并确认 Gate2 导航前、Gate3、最终控件可见、一次返回后四态;绝不点击最终提交。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
+49
-4
@@ -3,7 +3,7 @@ id: T-304
|
||||
title: 定时轮询、会话边界与采购工具主界面
|
||||
phase: 3
|
||||
deps: [T-303, T-006]
|
||||
status: TODO
|
||||
status: DOING
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 37
|
||||
context_ref: 526af1e
|
||||
@@ -15,14 +15,15 @@ write_paths:
|
||||
- client/src/cmbuyer_client/app.py
|
||||
- client/src/cmbuyer_client/polling/**
|
||||
- client/src/cmbuyer_client/ui/**
|
||||
- client/src/cmbuyer_client/localstate/models.py
|
||||
- client/src/cmbuyer_client/localstate/store.py
|
||||
- client/tests/test_app.py
|
||||
- client/tests/polling/**
|
||||
- client/tests/ui/**
|
||||
- docs/api.md
|
||||
- docs/06-tasks.md
|
||||
- client/tests/localstate/test_store.py
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=37 synced=2026-08-04T15:18:59Z sha256=70cde8bfacd40b2c2a7132dc0f0390689682403c7934587efb25348b40796826 -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=37 synced=2026-08-05T04:46:50Z sha256=326818109ef591a7d3127a407903cff26d178a83ac6505deca9acd30ebc1aed9 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-006 已确认采购工具双 Tab 原型,T-303 将提供唯一的 HTTP、本地恢复、DPAPI 与单实例底座。T-304 把原型转换为可测试的原生 PySide6 主界面和定时轮询协调器,但在真实单趟执行器接入前不得领取真实任务,避免形成无人消费的 active claim。
|
||||
@@ -59,11 +60,48 @@ T-006 已确认采购工具双 Tab 原型,T-303 将提供唯一的 HTTP、本
|
||||
- 应用独立启动、切换 Tab、Esc、查看记录与关闭窗口均产生零 claim、零 release、零 ADB;代码不导入 PDD 点击、数量、确认页、围栏、提交或付款能力。
|
||||
- client 全量 unittest、compileall、wheel metadata、根目录完整 init、Vikunja 导出、上下文校验与 diff-check 通过。
|
||||
|
||||
### T-303 metadata-only 配置摘要与 Stop epoch 补充
|
||||
|
||||
1. 允许精确扩展 localstate/models.py、store.py 与对应 test_store.py,只增加 metadata-only profile summary/settings 读取;不得调用 DPAPI 或返回 token/密文。
|
||||
2. Stop 必须提升 epoch/latch,使旧 timer dispatch 失效;飞行中结果先 durable commit,再按 CLAIMED/EMPTY/错误进入安全停止或恢复态。
|
||||
3. 存在 pending/active 时冻结全部非 token 配置,只允许同 device id 替换 token;关闭窗口不得 terminate 正在 I/O 的 QThread,须等待有界返回与落库。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T13:35:53Z · ila
|
||||
|
||||
2026-08-04 已按确认原型与 Windows UI/UX 规范落成任务:采用固定双 Tab、稳定 ID 的主从工作区、非模态持久错误、Esc 只退出记录详情、显式配置保存,以及停止仅阻止下一次领取的会话边界。独立运行时未注入单趟执行器,开始轮询必须禁用,确保零真实 claim、零 ADB;依赖 T-303、T-006 完成后再开工。
|
||||
|
||||
### 2026-08-04T17:56:06Z · ila
|
||||
|
||||
2026-08-05 T-304 代码实现与自动门禁已完成,任务继续保持 DOING,等待 needs_human_review 的原生 Windows 人工复核。
|
||||
|
||||
实现提交:42d52c8638f3d7fb3b500749ec2c9dd66d14303f;审计修复提交:73c438710bd975176d50166631a7ea2f6dbdc13f。已落地固定双 Tab、无真实 consumer 时禁领、长期 QThread worker 与 QTimer 调度、重启恢复与 Stop epoch/latch、严格歧义重放白名单、无秘密 UI DTO、配置保存/冻结语义、非模态记录详情和 Esc 返回。
|
||||
|
||||
第一轮 fixed-commit 审计发现并修复:65 位 token 被控件静默截断、consumer 无法获得本次 Start 冻结配置、metadata-only summary 对损坏密文/存储配置未充分失败闭合。新增首次及替换 token 零写入、consumer 趟内配置快照、空 BLOB/错误 storage class/损坏 URL、transport、range 且零 unprotect 回归。第二轮独立终审对 73c4387 结论为 PASS。
|
||||
|
||||
验证:聚焦 41 tests PASS;client 全量 229 tests PASS;compileall PASS;wheel build 与 verify_wheel_metadata PASS;仓库根 init.ps1 PASS(admin test/vet/build、client 229 tests、compileall、agent-context validator)。Qt offscreen 环境缺少可用字体目录,结构与交互截图已检查,但中文字体、DPI、主题、高对比度和纯键盘体验仍须在原生 Windows 人工验收,因此 done=false。
|
||||
|
||||
### 2026-08-04T18:02:15Z · ila
|
||||
|
||||
2026-08-05 主脑已将 T-304 合并到 main 并完成合并后验证。main 最终代码 SHA:3a27225;其中 UI 主提交:dfd88c3。
|
||||
|
||||
合并后仓库根 init.ps1 PASS;client 全量 232 tests PASS。设置 QT_QPA_FONTDIR=C:\Windows\Fonts 后完成离线截图复核,中文字与整体结构均可读,主脑视觉结构复核结论为 PASS。
|
||||
|
||||
当前仅完成自动化门禁与离线视觉结构复核。原生 Windows 下的 DPI、浅色/深色主题、高对比度、Narrator 以及纯键盘体验仍需人工验收,因此任务继续保持 DOING,Vikunja 保持 done=false 与 Doing。
|
||||
|
||||
### 2026-08-05T01:19:13Z · ila
|
||||
|
||||
2026-08-05 T-304 完整套件偶发测试修复:
|
||||
|
||||
- 完整测试套件暴露 `test_stop_during_claim_commits_then_requires_recovery_without_consumer_delivery` 与 FakeGateway 旧 2 秒等待位于同一时间边界的竞态;这是测试同步问题,不是生产轮询语义变化。
|
||||
- 提交 `c9b39a4` 仅在测试替身增加 worker `entered` event,并把 gate 改为 5 秒有界、超时显式失败;未修改生产代码。测试继续验证重复 dispatch、Stop epoch/latch、旧 epoch 拒绝、consumer 零投递与 durable recovery。
|
||||
- 独立审计 PASS;polling module 13 tests PASS,client 全量 233 tests PASS,仓库根 `init.ps1` PASS。
|
||||
- 原生 Windows 人工 UI 验收仍未完成;T-304 保持 done=false / Doing 与 needs_human_review。
|
||||
|
||||
### 2026-08-05T04:45:40Z · ila
|
||||
|
||||
2026-08-05 T-011 方案 B 由 T-304 所有者就地完成 polling 测试提速:仅修改 `client/tests/polling/test_coordinator.py`。13 个测试名完全不变,无删除/合并/skip;Qt 事件泵量子 10ms→1ms,删除同步 BLOCKED 路径无意义的固定 20ms 等待,并用“timer 未激活 + scheduled epoch 为空”的更强终态断言替代固定 30ms 等待。root 复跑 13/13 PASS,4.160s→3.574s;client 全量仍 255/255,12.892s(≤15s),compileall/diff-check/agent-context 通过。未改生产代码、轮询状态机或安全语义。T-304 仍因原生 Windows DPI/主题/高对比/Narrator/纯键盘人工验收保持 Doing。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
@@ -93,3 +131,10 @@ T-006 已确认采购工具双 Tab 原型,T-303 将提供唯一的 HTTP、本
|
||||
不停止轮询、不关闭窗口、不释放 claim、不改变服务端状态;返回后按稳定记录 ID 恢复选择、滚动和焦点。
|
||||
- 不导入或实现 PDD 页面判据、规格选择、数量、确认页、证据上传、事件、失败上报、提交围栏或结果接口;
|
||||
不编写或引用点击“提交订单”的代码,不编写支付、免密支付、先用后付或任何扣款控件代码。
|
||||
- T-303 的 `load_profile()` 会解密设备 token,配置页不得为了读取普通设置或“已保存”布尔状态调用它。
|
||||
本任务只可在既有 localstate store 增加 metadata-only 的 profile summary/settings 只读 API:沿用同一
|
||||
SQLite 表与事务,不调用 DPAPI、不返回密文或明文 token、不新增存储、mutex 或第二套状态源。
|
||||
- Stop 必须提升会话 epoch/latch,先取消 timer 并使已经排队的旧 epoch dispatch 失效。飞行中领取仍等待
|
||||
HTTP 自身有界结果并由 T-303 完成 durable commit:成功领取转 `RECOVERY_REQUIRED`,EMPTY 转
|
||||
`STOPPED`,结果不明、401、409、协议或本地错误保留原槽并停止;只有下一次显式 Start 才可同 key 恢复。
|
||||
关闭窗口同样不得 terminate 正在 I/O 的 QThread,必须等待其有界返回与落库后再结束 worker。
|
||||
|
||||
Reference in New Issue
Block a user