Files
Artemis-Eapil/tests/unit/admin_console/test_favorites.py
T

380 lines
15 KiB
Python

"""Favorite snapshots and transport retries never replay an old result or task."""
import asyncio
from copy import deepcopy
import json
from unittest.mock import AsyncMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import ValidationError
import pytest
from apps.admin_console.services import automation_service
from apps.admin_console.services.automation_store import AutomationStore
from apps.admin_console.services.favorites import (
FavoriteCreate,
FavoriteRun,
FavoriteService,
FavoriteUpdate,
)
from artemis.config.exploration import ExplorationConfig
from artemis.data_engine.storage import StorageManager
from artemis.exploration.models import CaseDraft
from artemis.exploration.store import ExplorationStore
@pytest.fixture
def favorites(tmp_path, monkeypatch):
storage = AutomationStore(tmp_path / "history.db")
StorageManager(storage.path, tmp_path / "traces")
monkeypatch.setattr(automation_service, "store", storage)
return FavoriteService(storage)
def task(service, session_id="task", **options):
config = {
"profile": "pro",
"model": "original-model",
"reasoning_effort": "high",
"device_serial": "original-device",
"record_video": True,
**options,
}
job = {
"session_id": session_id,
"status": "completed",
"case": {"name": "原用例", "goal": "检查真实页面"},
"execution_config": {
"runtime_overrides": config,
"expected_output": "保留证据",
"enable_outputter": True,
},
}
service.store.save_job(job)
return service.create(FavoriteCreate(source_id=session_id))
def test_snapshot_survives_source_deletion_and_edits_do_not_modify_source(favorites):
item = task(favorites)
updated = favorites.update(
item["id"], FavoriteUpdate(name="常用检查", task_config={"model": "new-model"})
)
assert updated["snapshot"]["runtime_overrides"]["model"] == "new-model"
assert (
favorites.store.job("task")["execution_config"]["runtime_overrides"]["model"]
== "original-model"
)
favorites.store.delete_history()
assert favorites.get(item["id"])["snapshot"]["goal"] == "检查真实页面"
assert favorites.store.job("task") is None
@pytest.mark.asyncio
async def test_rerun_preserves_settings_and_is_durably_idempotent(favorites, monkeypatch):
item = task(favorites)
enqueue = AsyncMock(return_value={"tasks": [{"session_id": "new-task"}], "status": "queued"})
monkeypatch.setattr("apps.admin_console.routers.tasks.run_task", enqueue)
value = FavoriteRun(request_id="same-submission", task_config={"reasoning_effort": "max"})
first, second = await asyncio.gather(
favorites.run(item["id"], value), favorites.run(item["id"], value)
)
assert first == second
enqueue.assert_awaited_once()
request = enqueue.call_args.args[0]
assert request.session_id != "task"
assert request.runtime_overrides.model == "original-model"
assert request.runtime_overrides.reasoning_effort == "max"
assert request.runtime_overrides.record_video is True
assert request.runtime_overrides.device_serial == "original-device"
assert request.expected_output == "保留证据"
assert favorites.get(item["id"])["snapshot"]["runtime_overrides"]["reasoning_effort"] == "high"
favorites.delete(item["id"])
assert await FavoriteService(favorites.store).run(item["id"], value) == first
enqueue.assert_awaited_once()
with pytest.raises(ValueError, match="request_id"):
await favorites.run(
item["id"],
FavoriteRun(request_id="same-submission", task_config={"model": "different"}),
)
@pytest.mark.asyncio
async def test_lost_admission_reply_reuses_snapshot_and_session(favorites, monkeypatch):
item = task(favorites)
enqueue = AsyncMock(side_effect=[ConnectionError("reply lost"), {"tasks": [{}]}])
monkeypatch.setattr("apps.admin_console.routers.tasks.run_task", enqueue)
value = FavoriteRun(request_id="retry")
with pytest.raises(ConnectionError):
await favorites.run(item["id"], value)
favorites.update(item["id"], FavoriteUpdate(task_config={"model": "changed"}))
await FavoriteService(favorites.store).run(item["id"], value)
requests = [call.args[0] for call in enqueue.call_args_list]
assert requests[0].session_id == requests[1].session_id
assert requests[1].runtime_overrides.model == "original-model"
@pytest.mark.asyncio
async def test_batch_favorite_keeps_one_round_and_original_repetitions(favorites):
batch = automation_service.create_batch(
{
"name": "压测",
"repeat_count": 3,
"profile": "flash",
"device_serial": "device",
"cases": [
{"name": "甲", "goal": "检查甲", "task_config": {"model": "model-a"}},
{
"name": "乙",
"goal": "检查乙",
"task_config": {"profile": "pro", "model": "model-b"},
},
],
}
)
item = favorites.create(FavoriteCreate(source_kind="batch", source_id=batch["id"]))
assert len(item["snapshot"]["cases"]) == 2
assert item["snapshot"]["repeat_count"] == 3
result = await favorites.run(
item["id"],
FavoriteRun(
request_id="batch-rerun",
task_config={"reasoning_effort": "max"},
case_configs={1: {"model": "changed-b"}},
),
)
jobs = favorites.store.jobs(result["batch_id"])
assert len(jobs) == 6
assert [j["case"]["name"] for j in jobs] == ["甲", "乙"] * 3
assert [j["case"]["task_config"]["model"] for j in jobs] == ["model-a", "changed-b"] * 3
assert all(j["case"]["task_config"]["reasoning_effort"] == "max" for j in jobs)
assert favorites.store.get("batch", result["batch_id"])["status"] == "running"
again = await favorites.run(
item["id"],
FavoriteRun(
request_id="batch-rerun",
task_config={"reasoning_effort": "max"},
case_configs={1: {"model": "changed-b"}},
),
)
assert again == result
assert len(favorites.store.records("batch")) == 2
def test_exploration_case_pins_version_without_inheriting_expensive_model(favorites):
repo = ExplorationStore(favorites.store)
session = repo.create(
"探索目标",
ExplorationConfig(model="exploration-model", reasoning_effort="max"),
{"model": "exploration-model", "device_serial": "device"},
"create",
)
repo.save_case(
session["id"],
CaseDraft(name="完成用例", goal="验证主页", preconditions="已登录"),
0,
"case-v1",
)
item = favorites.create(FavoriteCreate(source_kind="exploration_case", source_id=session["id"]))
assert item["case_version"] == 1
assert item["snapshot"]["goal"] == "前置条件:\n已登录\n\n验证主页"
assert item["snapshot"]["runtime_overrides"]["profile"] == "flash"
assert item["snapshot"]["runtime_overrides"]["model"] != "exploration-model"
repo.save_case(session["id"], CaseDraft(name="新用例", goal="新目标"), 1, "case-v2")
assert favorites.get(item["id"])["snapshot"]["goal"].endswith("验证主页")
exploration = favorites.create(
FavoriteCreate(source_kind="exploration", source_id=session["id"])
)
assert exploration["snapshot"]["config"]["model"] == "exploration-model"
def test_old_task_recovers_real_model_and_marks_missing_settings(favorites):
with favorites.store.connection() as conn:
conn.execute(
"INSERT INTO sessions(session_id,initial_goal,status,device_info) VALUES (?,?,?,?)",
(
"legacy",
"检查浏览器",
"completed",
json.dumps({"profile": "flash", "serial": "computer:local"}),
),
)
conn.execute(
"INSERT INTO traces(trace_id,session_id,type,timestamp,payload) VALUES (?,?,?,?,?)",
(
"request",
"legacy",
"llm_diagnostic",
1,
json.dumps(
{"role": "FlashRunner", "model": "actual-model", "reasoning_effort": "max"}
),
),
)
conn.commit()
item = favorites.create(FavoriteCreate(source_id="legacy"))
options = item["snapshot"]["runtime_overrides"]
assert options["model"] == "actual-model"
assert options["reasoning_effort"] == "max"
assert options["execution_target"] == "computer"
assert "model" not in item["inherited_fields"]
assert "recording" in item["inherited_fields"]
def test_api_rejects_credentials_and_arbitrary_snapshots(favorites, monkeypatch):
from apps.admin_console.routers import favorites as api
monkeypatch.setattr(api, "favorites", favorites)
app = FastAPI()
app.include_router(api.router)
client = TestClient(app)
item = task(favorites)
assert len(client.get("/api/automation/favorites").json()) == 1
assert (
client.patch(
f"/api/automation/favorites/{item['id']}", json={"task_config": {"api_key": "never"}}
).status_code
== 422
)
assert (
client.post(
"/api/automation/favorites", json={"source_id": "task", "snapshot": {}}
).status_code
== 422
)
assert (
client.patch(f"/api/automation/favorites/{item['id']}", json={"name": " "}).status_code
== 409
)
assert client.delete(f"/api/automation/favorites/{item['id']}").status_code == 200
assert favorites.store.job("task")
with pytest.raises(ValidationError):
FavoriteRun(request_id="run", task_config={"password": "never"})
def test_collection_defaults_cannot_modify_original_snapshot(favorites):
item = task(favorites)
original = deepcopy(item)
favorites.updated(item, FavoriteUpdate(task_config={"model": "temporary"}))
assert item == original
def test_partial_recording_update_keeps_other_favorite_settings(favorites):
item = task(favorites, recording={"codec": "h264", "fps": 10, "max_long_edge": 720})
updated = favorites.updated(item, FavoriteUpdate(task_config={"recording": {"codec": "h265"}}))
assert updated["snapshot"]["runtime_overrides"]["recording"] == {
**item["snapshot"]["runtime_overrides"]["recording"],
"codec": "h265",
}
assert item["snapshot"]["runtime_overrides"]["recording"]["codec"] == "h264"
def test_legacy_empty_recording_configuration_uses_current_defaults():
from apps.admin_console.services.execution_config import execution_config
from artemis.config.agent import load_agent_config
snapshot = execution_config({"profile": "flash", "runtime_overrides": {"recording": None}})
assert snapshot["runtime_overrides"]["recording"] == load_agent_config().recording.model_dump()
def test_legacy_favorite_is_completed_once_without_changing_actual_settings(favorites, monkeypatch):
from artemis.config.settings import settings
item = task(favorites, max_retries=0, record_video=False, recording={"fps": 10})
item.pop("config_version")
original = deepcopy(item)
favorites.store.put("favorite", item["id"], item)
completed = favorites.list()[0]
options = completed["snapshot"]["runtime_overrides"]
assert options["model"] == "original-model"
assert options["max_retries"] == 0
assert options["record_video"] is False
assert options["recording"]["fps"] == 10
assert options["recording"]["max_long_edge"] > 0
assert completed["created_at"] == original["created_at"]
assert completed["updated_at"] == original["updated_at"]
assert favorites.store.get("favorite", item["id"]) == completed
monkeypatch.setattr(settings, "OPENAI_MODEL", "new-global-model")
assert FavoriteService(favorites.store).get(item["id"]) == completed
def test_partial_legacy_snapshot_and_batch_complete_missing_fields(favorites):
from apps.admin_console.services.execution_config import execution_config
expected = execution_config({})["runtime_overrides"]
item = task(favorites)
item.pop("config_version")
item["snapshot"]["runtime_overrides"] = {"profile": "pro", "model": "kept-model"}
favorites.store.put("favorite", item["id"], item)
options = favorites.get(item["id"])["snapshot"]["runtime_overrides"]
assert options["model"] == "kept-model"
assert options["reasoning_effort"] == expected["reasoning_effort"]
assert options["recording"] == expected["recording"]
item["id"] = "legacy-batch"
item["kind"] = "batch"
item["snapshot"] = {
"profile": "pro",
"device_serial": "device",
"cases": [
{
"name": "一",
"goal": "检查",
"task_config": {"model": "case-model", "record_video": False},
}
],
}
favorites.store.put("favorite", item["id"], item)
options = favorites.get(item["id"])["snapshot"]["cases"][0]["task_config"]
assert options["model"] == "case-model"
assert options["profile"] == "pro"
assert options["device_serial"] == "device"
assert options["record_video"] is False
assert options["request_timeout_seconds"] == expected["request_timeout_seconds"]
@pytest.mark.asyncio
@pytest.mark.parametrize("rejected", [False, True])
async def test_restart_exploration_creates_new_conversation_and_reports_rejection(
favorites, monkeypatch, rejected
):
from apps.admin_console.services.exploration_service import ExplorationService
repo = ExplorationStore(favorites.store)
original = repo.create(
"探索目标",
ExplorationConfig(model="explore-model", reasoning_effort="max"),
{"device_serial": "device", "record_video": True},
"source",
)
item = favorites.create(FavoriteCreate(source_kind="exploration", source_id=original["id"]))
monkeypatch.setattr(
"apps.admin_console.services.exploration_service.exploration_service",
ExplorationService(repo),
)
enqueue = AsyncMock(
return_value=(
{"status": "rejected", "error": "设备离线"}
if rejected
else {"status": "queued", "tasks": [{}]}
)
)
monkeypatch.setattr("apps.admin_console.routers.tasks.run_task", enqueue)
value = FavoriteRun(request_id="new-exploration", task_config={"model": "selected-model"})
if rejected:
with pytest.raises(ValueError, match="设备离线"):
await favorites.run(item["id"], value)
with pytest.raises(ValueError, match="设备离线"):
await favorites.run(item["id"], value)
else:
result = await favorites.run(item["id"], value)
assert result["exploration_id"] != original["id"]
assert repo.run(result["session_id"])["exploration_id"] == result["exploration_id"]
assert await favorites.run(item["id"], value) == result
request = enqueue.call_args.args[0]
assert request.conversation_id != original["id"]
assert request.runtime_overrides.model == "selected-model"
assert request.runtime_overrides.reasoning_effort == "max"
assert request.runtime_overrides.record_video is True
enqueue.assert_awaited_once()
assert repo.get(original["id"]) == original