Files
Artemis-Eapil/tests/unit/test_exploration_store.py
T

201 lines
8.3 KiB
Python

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=" ")