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()