from concurrent.futures import ThreadPoolExecutor import pytest from pydantic import ValidationError from apps.admin_console.services.automation_store import AutomationStore from artemis.config.exploration import ExplorationConfig from artemis.exploration.models import CaseDraft, CreateExploration from artemis.exploration.store import ExplorationConflict, ExplorationStore @pytest.fixture def repository(tmp_path): return ExplorationStore(AutomationStore(tmp_path / "history.db")) def create(repository, request_id="create-one"): return repository.create("探索相机夜视设置并验证画面", ExplorationConfig(), {}, request_id) def test_create_retry_is_idempotent_and_conflicting_request_is_rejected(repository): first = create(repository) assert create(repository)["id"] == first["id"] assert len(repository.list()) == 1 assert len(repository.messages(first["id"])) == 1 with pytest.raises(ExplorationConflict): repository.create("另一个目标", ExplorationConfig(), {}, "create-one") assert len(repository.list()) == 1 def test_parallel_web_mcp_messages_are_additive_and_cursor_is_stable(repository): sid = create(repository)["id"] with ThreadPoolExecutor(max_workers=6) as pool: messages = list( pool.map( lambda i: repository.add_message(sid, f"指导 {i}", f"message-{i}", source="mcp"), range(24), ) ) assert len({m["sequence"] for m in messages}) == 24 assert repository.get(sid)["message_count"] == 25 first_page = repository.messages(sid, limit=10) next_page = repository.messages(sid, after=first_page[-1]["sequence"]) assert [m["sequence"] for m in first_page + next_page] == list(range(1, 26)) previous = repository.add_message(sid, "指导 0", "message-0", source="web") assert previous["source"] == "mcp" assert repository.get(sid)["message_count"] == 25 def test_parallel_start_reserves_only_one_actual_run(repository): sid = create(repository)["id"] def reserve(_): return repository.reserve_run( sid, "run-one", kind="exploration", goal="探索", task_config={} ) with ThreadPoolExecutor(max_workers=4) as pool: runs = list(pool.map(reserve, range(8))) assert len({r["id"] for r in runs}) == 1 assert len(repository.runs(sid)) == 1 with pytest.raises(ExplorationConflict): repository.reserve_run(sid, "run-two", kind="exploration", goal="探索", task_config={}) def test_case_versions_are_immutable_and_stale_edits_do_not_overwrite(repository): sid = create(repository)["id"] first = repository.save_case( sid, CaseDraft(name="夜视", goal="打开夜视并核对画面"), 0, "case-one" ) assert ( repository.save_case(sid, CaseDraft(name="夜视", goal="打开夜视并核对画面"), 0, "case-one") == first ) second = repository.save_case( sid, CaseDraft(name="夜视", goal="切换自动夜视并核对画面"), 1, "case-two" ) with pytest.raises(ExplorationConflict): repository.save_case(sid, CaseDraft(name="过期", goal="旧编辑"), 1, "stale") assert repository.case(sid, 1) == first assert repository.case(sid) == second assert repository.get(sid)["case_version"] == 2 def test_validation_pins_case_version_and_actual_verdict(repository): sid = create(repository)["id"] repository.save_case(sid, CaseDraft(name="夜视", goal="核对夜视"), 0, "save") with pytest.raises(ValueError, match="版本"): repository.reserve_run( sid, "bad-version", kind="validation", goal="复跑", task_config={}, case_version=2 ) run = repository.reserve_run( sid, "validation", kind="validation", goal="核对夜视", task_config={"profile": "flash"}, case_version=1, ) outcome = { "status": "completed", "test_result": {"status": "failed", "reason": "画面不符合预期"}, } repository.finish_run(run["id"], outcome) assert repository.run(run["id"])["outcome"] == outcome assert repository.run(run["id"])["case_version"] == 1 assert repository.get(sid)["active_run_id"] is None assert ( repository.finish_run( run["id"], {"status": "completed", "test_result": {"status": "passed"}} )["outcome"] == outcome ) def test_old_worker_cannot_change_new_run_state(repository): sid = create(repository)["id"] old = repository.reserve_run(sid, "old", kind="exploration", goal="探索", task_config={}) repository.finish_run(old["id"], {"status": "interrupted"}) new = repository.reserve_run(sid, "new", kind="exploration", goal="继续", task_config={}) with pytest.raises(ExplorationConflict): repository.set_run_state(old["id"], "running") assert repository.get(sid)["active_run_id"] == new["id"] def test_normal_exit_preserves_checker_verdict_and_incomplete_checks(repository): sid = create(repository)["id"] run = repository.reserve_run(sid, "start", kind="exploration", goal="探索", task_config={}) verified = { "task_status": "completed", "tests": {"passed": 1, "inconclusive": 1}, "test_result": { "status": "inconclusive", "reason": "缺少入口点击证据", "evidence": ["搜索页可见"], }, } repository.record_verdict(run["id"], verified) result = repository.finish_run(run["id"], {"status": "completed", "test_result": None}) assert result["outcome"] == {**verified, "status": "completed"} assert "缺少入口点击证据" in repository.messages(sid)[-1]["text"] def test_execution_budget_pause_does_not_finish_run_or_create_failed_verdict(repository): sid = create(repository)["id"] run = repository.reserve_run(sid, "run", kind="exploration", goal="探索", task_config={}) repository.set_run_state(run["id"], "running", elapsed_seconds=10) repository.set_run_state(run["id"], "paused", reason="execution_budget", elapsed_seconds=1800) assert repository.run(run["id"])["outcome"] is None assert repository.get(sid)["active_run_id"] == run["id"] assert repository.get(sid)["elapsed_seconds"] == 1800 repository.set_run_state(run["id"], "running", elapsed_seconds=1800) repository.set_run_state(run["id"], "running", elapsed_seconds=1810) assert repository.get(sid)["elapsed_seconds"] == 1810 def test_questions_and_answers_keep_origin_and_order(repository): sid = create(repository)["id"] run = repository.reserve_run(sid, "run", kind="exploration", goal="探索", task_config={}) question = repository.ask(run["id"], "预期需要自动切换吗?", "建议验证自动切换", "q1") assert repository.get(sid)["pending_question"] == question["id"] assert repository.ask(run["id"], "预期需要自动切换吗?", "建议验证自动切换", "q1") == question answer = repository.add_message( sid, "是", "answer", source="mcp", metadata={"question_id": question["id"]} ) assert answer["sequence"] == question["sequence"] + 1 assert answer["question_id"] == question["id"] def test_generated_case_cannot_borrow_another_sessions_evidence(repository): sid = create(repository)["id"] other = create(repository, "other")["id"] run = repository.reserve_run(other, "run", kind="exploration", goal="探索", task_config={}) repository.finish_run(run["id"], {"status": "completed"}) with pytest.raises(ValueError, match="不属于"): repository.save_case( sid, CaseDraft(name="用例", goal="目标"), 0, "case", source_run_id=run["id"], generated=True, ) assert repository.get(sid)["case_version"] == 0 def test_exploration_config_defaults_and_rejects_credentials(): config = ExplorationConfig() assert (config.model, config.reasoning_effort, config.timeout_seconds) == ( "gpt-6.1-sol", "high", 1800, ) assert config.demonstration_timeout_seconds == 900 with pytest.raises(ValidationError): ExplorationConfig.model_validate({"api_key": "not-allowed"}) with pytest.raises(ValidationError): ExplorationConfig(demonstration_timeout_seconds=60, demonstration_warning_seconds=60) with pytest.raises(ValidationError): CreateExploration(request_id="r", goal=" ")