Files
Artemis-Eapil/tests/unit/test_exploration_service.py

323 lines
15 KiB
Python

from unittest.mock import AsyncMock
import pytest
from pydantic import ValidationError
from apps.admin_console.services.automation_store import AutomationStore
from apps.admin_console.services.exploration_service import ExplorationService
from artemis.config.exploration import ExplorationConfig
from artemis.exploration.models import (
CaseBatchRequest,
CreateExploration,
CaseDraft,
ValidateCaseRequest,
)
from artemis.exploration.store import ExplorationStore
from artemis.runtime import task_control
@pytest.fixture
def service(tmp_path, monkeypatch):
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path / "traces")
return ExplorationService(ExplorationStore(AutomationStore(tmp_path / "history.db")))
@pytest.mark.asyncio
async def test_admission_uses_shared_queue_with_independent_model(service, monkeypatch):
enqueue = AsyncMock(return_value={"status": "queued", "tasks": []})
monkeypatch.setattr("apps.admin_console.routers.tasks.run_task", enqueue)
session = service.create(CreateExploration(goal="探索设置", request_id="create"))
await service.start(session["id"], "start")
request = enqueue.call_args.args[0]
assert request.profile == "pro"
assert request.runtime_overrides.model == "gpt-6.1-terra"
assert request.runtime_overrides.reasoning_effort == "high"
assert request.runtime_overrides.verification_level == "final"
assert request.conversation_id == session["id"]
first_id = request.session_id
await service.start(session["id"], "start")
assert enqueue.call_args.args[0].session_id == first_id
@pytest.mark.asyncio
async def test_validation_keeps_version_and_does_not_inherit_exploration_model(
service, monkeypatch
):
enqueue = AsyncMock(return_value={"status": "queued"})
monkeypatch.setattr("apps.admin_console.routers.tasks.run_task", enqueue)
session = service.create(
CreateExploration(
goal="探索设置",
request_id="create",
task_config={"model": "expensive", "reasoning_effort": "max"},
)
)
service.repository.save_case(session["id"], CaseDraft(name="设置", goal="复跑设置"), 0, "save")
await service.validate(session["id"], ValidateCaseRequest(request_id="validate", version=1))
request = enqueue.call_args.args[0]
assert request.profile == "flash"
assert request.runtime_overrides.model is None
assert request.runtime_overrides.reasoning_effort is None
assert request.goal == "复跑设置"
@pytest.mark.asyncio
async def test_rejected_admission_releases_conversation_for_corrected_request(service, monkeypatch):
from fastapi import HTTPException
monkeypatch.setattr(
"apps.admin_console.routers.tasks.run_task",
AsyncMock(side_effect=HTTPException(409, "设备锁定")),
)
session = service.create(CreateExploration(goal="探索设置", request_id="create"))
with pytest.raises(HTTPException):
await service.start(session["id"], "start")
assert service.repository.get(session["id"])["active_run_id"] is None
assert service.repository.runs(session["id"])[0]["outcome"]["status"] == "rejected"
def test_retrying_answer_does_not_duplicate_guidance_or_answer_a_new_question(service):
session = service.create(CreateExploration(goal="探索设置", request_id="create"))
sid = session["id"]
run = service.repository.reserve_run(
sid, "run", kind="exploration", goal="探索", task_config={}
)
first = service.repository.ask(run["id"], "第一个问题", "建议", "q1")
reply = service.message(sid, "可以", "answer")
consumed = task_control.consume_instructions(task_control.control_dir(run["id"]))
assert consumed["instruction"] == "可以"
second = service.repository.ask(run["id"], "第二个问题", "建议", "q2")
assert service.message(sid, "可以", "answer") == reply
assert first["id"] == reply["question_id"]
assert task_control.consume_instructions(task_control.control_dir(run["id"])) is None
assert service.repository.get(sid)["pending_question"] == second["id"]
@pytest.mark.asyncio
async def test_retrying_continue_after_run_ended_never_starts_another_run(service, monkeypatch):
session = service.create(CreateExploration(goal="探索", request_id="create"))
run = service.repository.reserve_run(
session["id"], "start", kind="exploration", goal="探索", task_config={}
)
monkeypatch.setattr(
"apps.admin_console.services.exploration_service.read_state",
lambda _: {"status": "task_paused"},
)
first = await service.resume(session["id"], "continue")
service.repository.finish_run(run["id"], {"status": "completed"})
assert await service.resume(session["id"], "continue") == first
assert len(service.repository.runs(session["id"])) == 1
def test_validation_guidance_is_delivered_and_counted_once(service):
session = service.create(CreateExploration(goal="探索", request_id="create"))
service.repository.save_case(session["id"], CaseDraft(name="设置", goal="复跑设置"), 0, "save")
run = service.repository.reserve_run(
session["id"], "validate", kind="validation", goal="复跑", task_config={}, case_version=1
)
service.message(session["id"], "只检查页面,不更改设置", "guide")
service.message(session["id"], "只检查页面,不更改设置", "guide")
assert service.repository.run(run["id"])["user_guidance_count"] == 1
assert (
task_control.consume_instructions(task_control.control_dir(run["id"]))["instruction"]
== "只检查页面,不更改设置"
)
def test_seal_rejects_unreviewed_guidance_then_refuses_silent_editorial_delivery(service):
from artemis.exploration.store import ExplorationConflict
session = service.create(CreateExploration(goal="探索", request_id="create"))
run = service.repository.reserve_run(
session["id"], "run", kind="exploration", goal="探索", task_config={}
)
service.message(session["id"], "先回到主页,再结束", "home")
assert not service.repository.seal_execution(run["id"], 0)
assert service.repository.seal_execution(run["id"], 1)
with pytest.raises(ExplorationConflict, match="指导尚未发送"):
service.message(session["id"], "再检查一次", "late")
assert service.repository.run(run["id"])["user_guidance_count"] == 1
assert service.repository.messages(session["id"])[-1]["text"] == "先回到主页,再结束"
@pytest.mark.asyncio
async def test_continue_cannot_skip_an_unanswered_question(service):
from artemis.exploration.store import ExplorationConflict
session = service.create(CreateExploration(goal="探索", request_id="create"))
run = service.repository.reserve_run(
session["id"], "run", kind="exploration", goal="探索", task_config={}
)
service.repository.ask(run["id"], "下一步?", "返回主页", "ask")
with pytest.raises(ExplorationConflict, match="先回答"):
await service.resume(session["id"], "resume")
def test_validation_uses_saved_case_title_without_replacing_its_full_goal(service):
from apps.admin_console.services.exploration_listing import exploration_metadata
session = service.create(CreateExploration(goal="探索设置", request_id="create"))
service.repository.save_case(
session["id"], CaseDraft(name="深色主题检查", goal="完整执行提示词"), 0, "save"
)
run = service.repository.reserve_run(
session["id"],
"validate",
kind="validation",
goal="完整执行提示词",
task_config={},
case_version=1,
)
with service.repository.database.connection() as connection:
metadata = exploration_metadata(connection)[run["id"]]
assert metadata["display_name"] == "深色主题检查"
assert metadata["case_version"] == 1
assert service.repository.run(run["id"])["goal"] == "完整执行提示词"
def test_execution_context_omits_duplicate_assistant_narration_but_retains_guidance(service):
session = service.create(CreateExploration(goal="探索设置", request_id="create"))
service.repository.add_message(session["id"], "正在读取页面", "narration", role="assistant")
service.repository.add_message(
session["id"], "下一步?", "question", role="assistant", metadata={"kind": "question"}
)
service.repository.add_message(session["id"], "返回主页", "home")
texts = [
m["text"] for m in service.repository.recent_messages(session["id"], guidance_only=True)
]
assert texts == ["探索设置", "下一步?", "返回主页"]
assert len(service.repository.recent_messages(session["id"])) == 4
def test_independent_saved_defaults_do_not_change_existing_sessions(service):
session = service.create(CreateExploration(goal="探索设置", request_id="create"))
service.save_settings(ExplorationConfig(model="gpt-6-astra", reasoning_effort="max"))
later = service.create(CreateExploration(goal="另一次探索", request_id="later"))
assert service.repository.get(session["id"])["config"]["model"] == "gpt-6.1-terra"
assert later["config"]["model"] == "gpt-6-astra"
def test_creation_rejects_secrets_and_unknown_task_options(service):
with pytest.raises(ValidationError):
service.create(
CreateExploration(goal="探索", request_id="create", task_config={"api_key": "secret"})
)
assert service.repository.list() == []
@pytest.mark.asyncio
async def test_fast_worker_state_cannot_be_replaced_with_queued(service, monkeypatch):
async def enqueue(request):
service.repository.set_run_state(request.session_id, "running")
return {"status": "started"}
monkeypatch.setattr("apps.admin_console.routers.tasks.run_task", enqueue)
session = service.create(CreateExploration(goal="探索设置", request_id="create"))
await service.start(session["id"], "start")
assert service.repository.get(session["id"])["status"] == "running"
@pytest.mark.asyncio
async def test_rerun_includes_user_edited_preconditions(service, monkeypatch):
enqueue = AsyncMock(return_value={"status": "queued"})
monkeypatch.setattr("apps.admin_console.routers.tasks.run_task", enqueue)
session = service.create(CreateExploration(goal="探索", request_id="create"))
service.repository.save_case(
session["id"],
CaseDraft(name="用例", goal="观察设置", preconditions="已连接测试网络"),
0,
"save",
)
await service.validate(session["id"], ValidateCaseRequest(request_id="validate", version=1))
assert "已连接测试网络" in enqueue.call_args.args[0].goal
@pytest.mark.asyncio
@pytest.mark.parametrize("field", ["goal", "preconditions"])
async def test_unresolved_case_never_reserves_run_or_creates_batch(service, monkeypatch, field):
from apps.admin_console.services import automation_service
enqueue = AsyncMock()
monkeypatch.setattr("apps.admin_console.routers.tasks.run_task", enqueue)
monkeypatch.setattr(automation_service, "store", service.repository.database)
session = service.create(CreateExploration(goal="探索", request_id="create"))
draft = {"name": "旧版用例", "goal": "检查页面", field: "报告保存到 ${报告位置}"}
service.repository.save_case(session["id"], CaseDraft(**draft), 0, "save")
with pytest.raises(ValueError, match=r"未填写参数.*\$\{报告位置\}"):
await service.validate(session["id"], ValidateCaseRequest(request_id="validate", version=1))
with pytest.raises(ValueError, match="未填写参数"):
service.create_case_batch(session["id"], CaseBatchRequest(request_id="batch", version=1))
enqueue.assert_not_called()
assert service.repository.runs(session["id"]) == []
assert service.repository.database.records("batch") == []
assert service.repository.case(session["id"])["version"] == 1
# A saved correction can use the same admission request ID; rejection created no run.
service.repository.save_case(
session["id"],
CaseDraft(name="可执行", goal="检查页面,报告保存到本次任务 trace"),
1,
"edit",
)
enqueue.return_value = {"status": "queued"}
await service.validate(session["id"], ValidateCaseRequest(request_id="validate", version=2))
enqueue.assert_awaited_once()
@pytest.mark.parametrize("profile", ["flash", "pro"])
def test_case_batch_creates_only_an_idempotent_draft_with_bound_resources(
service, monkeypatch, profile
):
from apps.admin_console.services import automation_service
monkeypatch.setattr(automation_service, "store", service.repository.database)
session = service.create(
CreateExploration(
goal="探索设置",
request_id="create",
task_config={
"device_serial": "test-device",
"execution_target": "android",
"model": "exploration-model",
"reasoning_effort": "max",
"timeout_seconds": 900,
},
)
)
service.repository.save_case(
session["id"],
CaseDraft(name="设置", goal="观察设置", preconditions="已解锁"),
0,
"save",
)
request = CaseBatchRequest(request_id="batch", version=1, profile=profile)
batch = service.create_case_batch(session["id"], request)
assert service.create_case_batch(session["id"], request)["id"] == batch["id"]
assert batch["status"] == "draft"
jobs = service.repository.database.jobs(batch["id"])
assert len(jobs) == 1
assert jobs[0]["status"] == "pending"
case = jobs[0]["case"]
assert case["case_id"] == f"{session['id']}-v1"
assert case["category"] == "探索用例"
assert "已解锁" in case["goal"]
assert case["task_config"]["device_serial"] == "test-device"
assert case["task_config"]["execution_target"] == "android"
assert case["task_config"]["profile"] == profile
assert case["task_config"]["model"] is None
assert case["task_config"]["reasoning_effort"] is None
assert case["task_config"]["timeout_seconds"] is None
assert service.repository.runs(session["id"]) == []
def test_stale_validation_guidance_never_targets_a_new_active_run(service):
from artemis.exploration.store import ExplorationConflict
session = service.create(CreateExploration(goal="探索", request_id="create"))
run = service.repository.reserve_run(
session["id"], "run", kind="exploration", goal="探索", task_config={}
)
with pytest.raises(ExplorationConflict, match="所选执行已结束"):
service.message(session["id"], "旧复跑的指导", "stale", expected_run_id="previous-run")
assert task_control.list_instructions(run["id"]) == []