from __future__ import annotations
import base64
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from autotrain_next import ledger, paths, platform, profiles, sampling, vision, web
from autotrain_next.planner import PlanError, deterministic_plan, validate_llm_steps
class NextGenTests(unittest.TestCase):
def test_adb_prefers_explicit_environment(self) -> None:
with patch.dict("os.environ", {"AUTOTRAIN_ADB": "C:/sdk/adb.exe"}, clear=True):
self.assertEqual(platform.adb_binary(), "C:/sdk/adb.exe")
def test_runtime_paths_have_no_parent_project_root(self) -> None:
self.assertEqual(paths.ROOT, ROOT)
self.assertEqual(paths.CONFIG.parent, paths.ROOT)
self.assertEqual(paths.WORK.parent, paths.ROOT)
def test_vision_reads_ignored_local_key_when_environment_is_absent(self) -> None:
with tempfile.TemporaryDirectory() as directory:
config = Path(directory) / "config.local.json"
config.write_text(json.dumps({"vision": {
"base_url": "https://api.siliconflow.cn/v1",
"model": "test-vision-model",
"api_key": "test-local-key",
}}), encoding="utf-8")
with patch.object(vision, "CONFIG", config), patch.dict("os.environ", {}, clear=True):
resolved = vision._config()
self.assertEqual(resolved["api_key"], "test-local-key")
self.assertEqual(resolved["model"], "test-vision-model")
def test_normalized_point_scales_with_screen(self) -> None:
self.assertEqual(profiles.normalized_point((0.5, 0.25), (1080, 2400)), (540, 600))
def test_profile_with_rotation_change_is_not_ready(self) -> None:
profile = {"status": "verified", "device": {"serial": "a", "orientation": "0"}}
check = profiles.compatibility(profile, {"serial": "a", "orientation": "1"})
self.assertEqual(check["status"], "draft")
self.assertIn("屏幕方向已变化", check["reasons"])
def test_llm_rejects_unknown_or_unapproved_tap(self) -> None:
with self.assertRaisesRegex(PlanError, "未注册"):
validate_llm_steps([{"tool": "shell.rm", "arguments": {}}], allow_submit=False)
with self.assertRaisesRegex(PlanError, "禁止"):
validate_llm_steps([{"tool": "ui.tap_normalized", "arguments": {"point": [0.5, 0.5]}}], allow_submit=False)
def test_list_page_routes_to_native_open_tool(self) -> None:
task = {"allow_submit": True, "history": [{"tool": "observe.xml", "result": {"texts": ["张*三", "开具"]}}]}
self.assertEqual(deterministic_plan(task)[0].tool, "invoice.open_next")
def test_unknown_xml_falls_back_to_vision_inspection(self) -> None:
task = {"allow_submit": True, "history": [{"tool": "observe.xml", "result": {"texts": []}}]}
self.assertEqual(deterministic_plan(task)[0].tool, "invoice.inspect")
def test_vision_inspection_routes_its_detected_page(self) -> None:
task = {"allow_submit": True, "history": [{"tool": "observe.xml", "result": {"texts": []}}, {"tool": "invoice.inspect", "result": {"page": "verify"}}]}
self.assertEqual(deterministic_plan(task)[0].tool, "invoice.verify")
def test_unrecognized_vision_page_yields_to_llm_or_review(self) -> None:
task = {"allow_submit": True, "history": [{"tool": "observe.xml", "result": {"texts": []}}, {"tool": "invoice.inspect", "result": {"page": "other"}}]}
self.assertEqual(deterministic_plan(task), [])
def test_qr_task_starts_with_native_push_tool(self) -> None:
task = {"allow_submit": True, "navigation": {"phase": "push_qr"}, "history": []}
self.assertEqual(deterministic_plan(task)[0].tool, "nav.push_qr")
def test_success_routes_to_download_not_just_status_update(self) -> None:
task = {"allow_submit": True, "history": [{"tool": "observe.xml", "result": {"texts": ["发票开具成功"]}}]}
self.assertEqual(deterministic_plan(task)[0].tool, "invoice.download")
def test_success_can_use_email_result_action(self) -> None:
task = {"allow_submit": True, "invoice": {"success_action": "email"}, "history": [{"tool": "observe.xml", "result": {"texts": ["发票开具成功"]}}]}
self.assertEqual(deterministic_plan(task)[0].tool, "invoice.email")
def test_masked_name_match_uses_visible_id_edges(self) -> None:
roster = [{"姓名": "张三", "身份证号": "1101234567891234", "身份证后8位": "67891234"}]
self.assertEqual(ledger.match(roster, "张*", "1", "4"), roster)
self.assertEqual(ledger.match(roster, "张*", "2", "4"), [])
def test_import_recording_creates_draft_profile_from_snapshot(self) -> None:
with tempfile.TemporaryDirectory() as directory:
recording = Path(directory)
folder = recording / "001_list"; folder.mkdir()
(folder / "screen.png").write_bytes(b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x04\x38\x00\x00\x09\x60")
(recording / "session.json").write_text(json.dumps({"started_at": "2026-07-13", "device": {"serial": "phone-a"}}), encoding="utf-8")
(recording / "timeline.json").write_text(json.dumps({"snapshots": [{"folder": "001_list", "page_hint": "list", "timestamp": "now"}]}), encoding="utf-8")
with patch.object(sampling, "save"):
result = sampling.import_recording(recording, {"serial": "phone-a", "model": "Test"})
self.assertEqual(result["status"], "draft")
self.assertEqual(result["sampling"]["samples"]["list"]["screen"], {"width": 1080, "height": 2400})
def test_xml_roster_extracts_name_and_identity_number(self) -> None:
with tempfile.TemporaryDirectory() as directory:
source = Path(directory) / "roster.xml"
source.write_text("", encoding="utf-8")
self.assertEqual(ledger.rows_from_xml(source), [{"姓名": "张三", "身份证号": "110101199001011234", "身份证后8位": "01011234", "来源文件": "roster.xml", "来源工作表": "passenger", "来源行号": "2"}])
def test_import_xml_roster_preserves_source_and_writes_canonical_csv(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root, source = Path(directory), Path(directory) / "roster.xml"
source.write_text("<姓名>李四姓名><身份证号>110101199001011235身份证号>", encoding="utf-8")
with patch.object(ledger, "WORK", root / "work"), patch.object(ledger, "ROSTER", root / "work" / "passenger_roster.csv"):
canonical = ledger.import_roster(source)
self.assertTrue(canonical.is_file())
self.assertEqual(ledger.read_roster(), [{"姓名": "李四", "身份证号": "110101199001011235", "身份证后8位": "01011235", "来源文件": "roster.xml", "来源工作表": "passenger", "来源行号": "2"}])
self.assertEqual(len(list((root / "work" / "imports" / "roster").glob("*.xml"))), 1)
def test_xlsx_roster_finds_header_after_title_row(self) -> None:
from openpyxl import Workbook
with tempfile.TemporaryDirectory() as directory:
source = Path(directory) / "roster.xlsx"
workbook = Workbook(); sheet = workbook.active
sheet.append(["某次出行名单"])
sheet.append(["序号", "姓名", "身份证号"])
sheet.append([1, "王五", "110101199001011236"])
workbook.save(source)
self.assertEqual(ledger.rows_from_xlsx(source), [{"姓名": "王五", "身份证号": "110101199001011236", "身份证后8位": "01011236", "来源文件": "roster.xlsx", "来源工作表": "Sheet", "来源行号": "3"}])
def test_qr_clipboard_image_accepts_png_and_rejects_non_image(self) -> None:
png = base64.b64encode(b"\x89PNG\r\n\x1a\nsmall").decode()
with tempfile.TemporaryDirectory() as directory, patch.object(web, "WORK", Path(directory)):
saved = web.save_qr_image(f"data:image/png;base64,{png}")
self.assertTrue(Path(saved["path"]).is_file())
with self.assertRaisesRegex(RuntimeError, "仅支持"):
web.save_qr_image("data:image/png;base64," + base64.b64encode(b"not-image").decode())
if __name__ == "__main__":
unittest.main()