feat: release 1.5.1 with human review and batch filters
This commit is contained in:
1 parent
53c5680260
commit
800d33c2cb
36 files changed
+672
-57
No files matched your search
@@ -13,7 +13,7 @@ from apps.admin_console.services import automation_service as service
|
||||
from apps.admin_console.services import batch_archive as archive_service
|
||||
from apps.admin_console.services import report_export, task_import
|
||||
from artemis.config.settings import settings
|
||||
from artemis.runtime.case_result import read_case_result
|
||||
from apps.admin_console.services.case_review import read_reviewed_result
|
||||
|
||||
router = APIRouter(prefix="/api/automation", tags=["automation"])
|
||||
|
||||
@@ -24,6 +24,12 @@ class ReportPreferences(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class CaseReviewRequest(BaseModel):
|
||||
model_config = {"extra": "forbid"}
|
||||
status: Literal["passed", "failed"]
|
||||
reason: str = Field(default="", max_length=2000)
|
||||
|
||||
|
||||
class BatchRequest(ReportPreferences):
|
||||
request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,100}$")
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
@@ -125,8 +131,8 @@ def batch_by_request(request_id: str):
|
||||
def batch_detail(batch_id: str):
|
||||
jobs = store.jobs(batch_id)
|
||||
for job in jobs:
|
||||
job["test_result"] = read_case_result(
|
||||
job["session_id"], job["status"], job.get("error") or ""
|
||||
job["test_result"] = read_reviewed_result(
|
||||
job["session_id"], job["status"], job.get("error") or "", storage=store
|
||||
)
|
||||
return {**require("batch", batch_id), "jobs": jobs}
|
||||
|
||||
@@ -207,6 +213,42 @@ async def download_report(
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
|
||||
|
||||
@router.put("/reports/{session_id}/review")
|
||||
def review_case(session_id: str, value: CaseReviewRequest):
|
||||
report(session_id)
|
||||
job = store.job(session_id) or {}
|
||||
batch_id = job.get("batch_id")
|
||||
if (
|
||||
batch_id
|
||||
and archive_service.status(batch_id, require("batch", batch_id))["status"] == "preparing"
|
||||
):
|
||||
raise HTTPException(409, "批次报告正在打包,请完成后再保存人工复验")
|
||||
with report_export.EXPORT_LOCK:
|
||||
current = report(session_id)
|
||||
if current["status"] not in {
|
||||
"completed",
|
||||
"success",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"interrupted",
|
||||
"skipped",
|
||||
}:
|
||||
raise HTTPException(409, "请在任务结束后进行人工复验")
|
||||
try:
|
||||
store.save_case_review(session_id, value.status, value.reason)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
if batch_id:
|
||||
archive_service.forget(batch_id)
|
||||
# A finishing worker can publish report_ready just after export;
|
||||
# removing only the generated ZIP also prevents that stale cache hit.
|
||||
try:
|
||||
archive_service.archive_path(batch_id).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass # An open download may hold the file; report_ready is already invalidated.
|
||||
return report(session_id)
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
def history(
|
||||
q: str = Query(default="", max_length=200),
|
||||
@@ -215,6 +257,7 @@ def history(
|
||||
end: float | None = None,
|
||||
page: int = Query(default=1, ge=1),
|
||||
size: int = Query(default=30, ge=1, le=100),
|
||||
batch_id: str = Query(default="", max_length=100),
|
||||
):
|
||||
# SQL filtering/pagination searches the entire persisted history, not a UI cache.
|
||||
query = """WITH history AS (
|
||||
@@ -226,7 +269,7 @@ def history(
|
||||
COALESCE(json_extract(j.payload,'$.case.category'),'') category,
|
||||
COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory,
|
||||
COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions,
|
||||
COALESCE(json_extract(j.payload,'$.error'),'') error
|
||||
COALESCE(json_extract(j.payload,'$.error'),'') error, j.batch_id
|
||||
FROM sessions s LEFT JOIN automation_jobs j ON s.session_id=j.session_id
|
||||
UNION ALL
|
||||
SELECT j.session_id,json_extract(j.payload,'$.case.goal'),
|
||||
@@ -237,11 +280,22 @@ def history(
|
||||
COALESCE(json_extract(j.payload,'$.case.category'),''),
|
||||
COALESCE(json_extract(j.payload,'$.case.subcategory'),''),
|
||||
COALESCE(json_extract(j.payload,'$.case.preconditions'),''),
|
||||
COALESCE(json_extract(j.payload,'$.error'),'')
|
||||
COALESCE(json_extract(j.payload,'$.error'),''), j.batch_id
|
||||
FROM automation_jobs j WHERE NOT EXISTS (SELECT 1 FROM sessions s WHERE s.session_id=j.session_id)
|
||||
) """
|
||||
where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')"
|
||||
args = []
|
||||
if batch_id == "__batch__":
|
||||
where += " AND batch_id IS NOT NULL"
|
||||
elif batch_id == "__single__":
|
||||
where += " AND batch_id IS NULL"
|
||||
elif batch_id:
|
||||
try:
|
||||
identifier(batch_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
where += " AND batch_id = ?"
|
||||
args.append(batch_id)
|
||||
if q:
|
||||
where += " AND (name LIKE ? ESCAPE '\\' OR initial_goal LIKE ? ESCAPE '\\' OR session_id LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\' OR case_id LIKE ? ESCAPE '\\' OR category LIKE ? ESCAPE '\\' OR subcategory LIKE ? ESCAPE '\\' OR preconditions LIKE ? ESCAPE '\\')"
|
||||
escaped = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
||||
@@ -266,8 +320,8 @@ def history(
|
||||
items = [dict(row) for row in rows]
|
||||
jobs = store.jobs_for_sessions([item["session_id"] for item in items])
|
||||
for item in items:
|
||||
item["test_result"] = read_case_result(
|
||||
item["session_id"], item["status"], item.get("error") or ""
|
||||
item["test_result"] = read_reviewed_result(
|
||||
item["session_id"], item["status"], item.get("error") or "", storage=store
|
||||
)
|
||||
if job := jobs.get(item["session_id"]):
|
||||
item["name"] = task_import.execution_title(job)
|
||||
|
||||
@@ -20,7 +20,7 @@ from fastapi import APIRouter, HTTPException
|
||||
|
||||
from artemis.config import DB_PATH, TRACES_PATH
|
||||
from artemis.runtime import trace_store
|
||||
from artemis.runtime.case_result import read_case_result
|
||||
from apps.admin_console.services.case_review import read_reviewed_result
|
||||
|
||||
try:
|
||||
from admin_console.core.state import state
|
||||
@@ -83,6 +83,7 @@ def _list_sessions_sync():
|
||||
job = jobs_by_id.get(s_id)
|
||||
if job:
|
||||
case = job.get("case") or {}
|
||||
row_dict["batch_id"] = job.get("batch_id")
|
||||
row_dict["case_id"] = case.get("case_id") or ""
|
||||
row_dict["display_name"] = execution_title(job) or row_dict.get("initial_goal")
|
||||
row_dict["iteration"] = job.get("iteration", 1)
|
||||
@@ -131,8 +132,12 @@ def _list_sessions_sync():
|
||||
else:
|
||||
row_dict["video_url"] = None
|
||||
row_dict["recording_status"] = recording_status
|
||||
row_dict["test_result"] = read_case_result(
|
||||
s_id, row_dict.get("status") or "", row_dict.get("error") or "", root=TRACES_PATH
|
||||
row_dict["test_result"] = read_reviewed_result(
|
||||
s_id,
|
||||
row_dict.get("status") or "",
|
||||
row_dict.get("error") or "",
|
||||
root=TRACES_PATH,
|
||||
storage=store,
|
||||
)
|
||||
|
||||
agent_names = agent_names_by_session.get(s_id, [])
|
||||
@@ -194,8 +199,10 @@ async def get_session_details(session_id: str):
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
result = dict(row)
|
||||
result["test_result"] = read_case_result(
|
||||
session_id, result.get("status") or "", root=TRACES_PATH
|
||||
job = store.job(session_id) or {}
|
||||
result["batch_id"] = job.get("batch_id")
|
||||
result["test_result"] = read_reviewed_result(
|
||||
session_id, result.get("status") or "", root=TRACES_PATH, storage=store
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from artemis.core.diagnostics import readiness_engine
|
||||
from apps.admin_console.services.automation_store import store
|
||||
from apps.admin_console.services.task_import import execution_title
|
||||
from artemis.runtime import DeviceExecutionLock, device_pool
|
||||
from artemis.runtime.task_control import (
|
||||
add_instruction,
|
||||
@@ -423,6 +425,15 @@ async def get_status():
|
||||
seen_ids.add(sid)
|
||||
queue_data.append(g_item)
|
||||
|
||||
batch_jobs = store.jobs_for_sessions(
|
||||
[str(item["session_id"]) for item in [*queue_data, *active_tasks] if item.get("session_id")]
|
||||
)
|
||||
queue_data = [{**item} for item in queue_data]
|
||||
for item in [*queue_data, *active_tasks]:
|
||||
if job := batch_jobs.get(str(item.get("session_id"))):
|
||||
item["batch_id"] = job.get("batch_id")
|
||||
item["display_name"] = execution_title(job)
|
||||
|
||||
if is_running:
|
||||
is_paused = state.is_paused
|
||||
return {
|
||||
|
||||
@@ -149,6 +149,52 @@ class AutomationStore:
|
||||
def preferences(self) -> dict:
|
||||
return self.get("settings", "reports") or {"formats": ["docx", "xlsx"]}
|
||||
|
||||
def save_case_review(self, session_id: str, status: str, reason: str) -> dict:
|
||||
"""Append reviews atomically; invalidate the batch's generated report."""
|
||||
identifier(session_id)
|
||||
if status not in {"passed", "failed"}:
|
||||
raise ValueError("人工复验结果只能为通过或不通过")
|
||||
review = {
|
||||
"status": status,
|
||||
"reason": reason.strip()
|
||||
or ("人工复验确认通过。" if status == "passed" else "人工复验确认不通过。"),
|
||||
"reviewed_at": time.time(),
|
||||
}
|
||||
with self.connection() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute(
|
||||
"SELECT payload FROM automation_records WHERE kind='case_review' AND id=?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
record = json.loads(row[0]) if row else {"reviews": []}
|
||||
record["reviews"].append(review)
|
||||
conn.execute(
|
||||
"INSERT INTO automation_records VALUES (?,?,?,?) ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at",
|
||||
(
|
||||
"case_review",
|
||||
session_id,
|
||||
json.dumps(record, ensure_ascii=False),
|
||||
review["reviewed_at"],
|
||||
),
|
||||
)
|
||||
job = conn.execute(
|
||||
"SELECT batch_id FROM automation_jobs WHERE session_id=?", (session_id,)
|
||||
).fetchone()
|
||||
if job and job[0]:
|
||||
batch = conn.execute(
|
||||
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?", (job[0],)
|
||||
).fetchone()
|
||||
if not batch or json.loads(batch[0]).get("status") == "deleting":
|
||||
raise ValueError("批次正在删除,不能保存复验")
|
||||
payload = json.loads(batch[0])
|
||||
payload["report_ready"] = False
|
||||
conn.execute(
|
||||
"UPDATE automation_records SET payload=?,updated_at=? WHERE kind='batch' AND id=?",
|
||||
(json.dumps(payload, ensure_ascii=False), review["reviewed_at"], job[0]),
|
||||
)
|
||||
conn.commit()
|
||||
return review
|
||||
|
||||
def create_batch(self, batch: dict, jobs: list[dict], payload: dict) -> dict:
|
||||
"""Commit a batch and all its jobs atomically; repeated request IDs never duplicate work."""
|
||||
request_id = payload.get("request_id")
|
||||
@@ -247,6 +293,11 @@ class AutomationStore:
|
||||
batch = json.loads(row[0])
|
||||
if batch.get("status") != "deleting":
|
||||
raise ValueError("批次尚未准备删除")
|
||||
conn.execute(
|
||||
"DELETE FROM automation_records WHERE kind='case_review' AND id IN "
|
||||
"(SELECT session_id FROM automation_jobs WHERE batch_id=?)",
|
||||
(batch_id,),
|
||||
)
|
||||
conn.execute("DELETE FROM automation_jobs WHERE batch_id=?", (batch_id,))
|
||||
conn.execute(
|
||||
"DELETE FROM automation_records WHERE kind='batch' AND id=?",
|
||||
@@ -260,9 +311,14 @@ class AutomationStore:
|
||||
with self.connection() as conn:
|
||||
if session_id:
|
||||
conn.execute("DELETE FROM automation_jobs WHERE session_id=?", (session_id,))
|
||||
conn.execute(
|
||||
"DELETE FROM automation_records WHERE kind='case_review' AND id=?",
|
||||
(session_id,),
|
||||
)
|
||||
else:
|
||||
conn.execute("DELETE FROM automation_jobs")
|
||||
conn.execute("DELETE FROM automation_records WHERE kind='batch'")
|
||||
conn.execute("DELETE FROM automation_records WHERE kind='case_review'")
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Human review overlays the AI verdict without changing execution evidence."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from apps.admin_console.services.automation_store import AutomationStore
|
||||
from artemis.runtime.case_result import read_case_result
|
||||
|
||||
|
||||
def reviewed_result(
|
||||
session_id: str, ai_result: dict | None, storage: AutomationStore
|
||||
) -> dict | None:
|
||||
original = {**ai_result, "source": "ai"} if ai_result else None
|
||||
record = storage.get("case_review", session_id)
|
||||
reviews = (record or {}).get("reviews") or []
|
||||
if not reviews:
|
||||
return original
|
||||
latest = reviews[-1]
|
||||
return {
|
||||
"status": latest["status"],
|
||||
"reason": latest["reason"],
|
||||
"evidence": [],
|
||||
"source": "human",
|
||||
"reviewed_at": latest["reviewed_at"],
|
||||
"ai_result": original,
|
||||
}
|
||||
|
||||
|
||||
def read_reviewed_result(
|
||||
session_id: str,
|
||||
execution_status: str,
|
||||
error: str = "",
|
||||
*,
|
||||
storage: AutomationStore,
|
||||
root: Path | None = None,
|
||||
) -> dict | None:
|
||||
return reviewed_result(
|
||||
session_id,
|
||||
read_case_result(session_id, execution_status, error, root=root),
|
||||
storage,
|
||||
)
|
||||
@@ -24,6 +24,7 @@ from apps.admin_console.database.repositories.step_repository import StepReposit
|
||||
from apps.admin_console.services.automation_store import store, identifier
|
||||
from apps.admin_console.services.task_import import execution_title
|
||||
from artemis.runtime.case_result import RESULT_LABELS, resolve_case_result
|
||||
from apps.admin_console.services.case_review import reviewed_result
|
||||
|
||||
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
|
||||
EXPORT_LOCK = threading.RLock()
|
||||
@@ -65,7 +66,10 @@ def text(value) -> str:
|
||||
def verdict(outcome: dict | None) -> str:
|
||||
result = (outcome or {}).get("test_result")
|
||||
if isinstance(result, dict) and result.get("status") in RESULT_LABELS:
|
||||
return RESULT_LABELS[result["status"]]
|
||||
label = RESULT_LABELS[result["status"]]
|
||||
if result.get("source"):
|
||||
label += "(人工确认)" if result["source"] == "human" else "(AI确认)"
|
||||
return label
|
||||
tests = (outcome or {}).get("tests") or {}
|
||||
if tests.get("failed", 0):
|
||||
return "验证失败"
|
||||
@@ -183,9 +187,10 @@ def session_report(session_id: str, db_path=None) -> dict:
|
||||
case = meta.get("case") or {}
|
||||
status = meta.get("terminal_status") or meta.get("status") or session.get("status", "unknown")
|
||||
error = failure_reason(directory, status, meta.get("error"))
|
||||
test_result = resolve_case_result(outcome, status, error)
|
||||
ai_result = resolve_case_result(outcome, status, error)
|
||||
test_result = reviewed_result(session_id, ai_result.model_dump() if ai_result else None, store)
|
||||
if test_result:
|
||||
outcome = {**outcome, "test_result": test_result.model_dump()}
|
||||
outcome = {**outcome, "test_result": test_result}
|
||||
verification = verdict(outcome)
|
||||
if status in {"failed", "interrupted"} and verification.startswith("未验证"):
|
||||
verification = "未验证(任务异常终止)"
|
||||
@@ -204,7 +209,7 @@ def session_report(session_id: str, db_path=None) -> dict:
|
||||
"expected": case.get("expected", ""),
|
||||
"status": status,
|
||||
"verdict": verification,
|
||||
"test_result": test_result.model_dump() if test_result else None,
|
||||
"test_result": test_result,
|
||||
"outcome": outcome,
|
||||
"content": content,
|
||||
"steps": steps,
|
||||
@@ -241,6 +246,13 @@ def metadata(report: dict) -> list[tuple]:
|
||||
("预期结果", report["expected"] or "未指定"),
|
||||
("执行状态", STATUS.get(report["status"], report["status"])),
|
||||
("验证结论", report["verdict"]),
|
||||
("人工复验时间", time_text((report.get("test_result") or {}).get("reviewed_at"))),
|
||||
(
|
||||
"原 AI 结论",
|
||||
verdict({"test_result": (report.get("test_result") or {}).get("ai_result")})
|
||||
if (report.get("test_result") or {}).get("source") == "human"
|
||||
else "—",
|
||||
),
|
||||
("判定依据", (report.get("test_result") or {}).get("reason") or "未记录"),
|
||||
(
|
||||
"验收证据",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^22.1.5",
|
||||
"@angular/common": "^22.1.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve --proxy-config proxy.conf.json",
|
||||
|
||||
@@ -44,7 +44,7 @@ limitations under the License.
|
||||
</button>
|
||||
<button #automationOrigin="cdkOverlayOrigin" cdkOverlayOrigin class="vscode-tab-btn" [class.active]="isAutomationOpen()" (click)="isAutomationOpen.set(!isAutomationOpen())" title="报告与批量" aria-label="报告与批量"><span class="material-symbols-outlined tab-icon">assignment</span><span>报告与批量</span></button>
|
||||
</div>
|
||||
<ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="automationOrigin" [cdkConnectedOverlayOpen]="isAutomationOpen()" [cdkConnectedOverlayHasBackdrop]="true" cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop" (backdropClick)="isAutomationOpen.set(false)" [cdkConnectedOverlayPositions]="dropdownPositions"><div class="vscode-dropdown-menu" style="width:min(560px,calc(100vw - 24px));max-height:85vh;overflow:auto;padding:20px"><div class="popover-title-header" style="justify-content:space-between;margin-bottom:6px"><span>报告与自动化</span><button type="button" (click)="isAutomationOpen.set(false)" aria-label="关闭报告与自动化" style="border:0;background:transparent;color:#64748b;cursor:pointer;padding:4px"><span class="material-symbols-outlined">close</span></button></div><app-automation-panel /></div></ng-template>
|
||||
<ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="automationOrigin" [cdkConnectedOverlayOpen]="isAutomationOpen()" [cdkConnectedOverlayHasBackdrop]="true" cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop" (backdropClick)="isAutomationOpen.set(false)" [cdkConnectedOverlayPositions]="dropdownPositions"><div class="vscode-dropdown-menu" style="width:min(560px,calc(100vw - 24px));max-height:85vh;overflow:auto;padding:20px"><div class="popover-title-header" style="justify-content:space-between;margin-bottom:6px"><span>报告与自动化</span><button type="button" (click)="isAutomationOpen.set(false)" aria-label="关闭报告与自动化" style="border:0;background:transparent;color:#64748b;cursor:pointer;padding:4px"><span class="material-symbols-outlined">close</span></button></div><app-automation-panel (queueRequested)="openBatchQueue()" /></div></ng-template>
|
||||
|
||||
<!-- Dropdown Popover Panel for Task Queue -->
|
||||
<ng-template
|
||||
@@ -61,6 +61,7 @@ limitations under the License.
|
||||
<span class="material-symbols-outlined">list_alt</span>
|
||||
<span>任务队列</span>
|
||||
</div>
|
||||
<app-task-batch-filter />
|
||||
<div class="dropdown-section-label">
|
||||
<span class="material-symbols-outlined icon-bolt">bolt</span>
|
||||
<span>当前队列 ({{ activeQueue().length }})</span>
|
||||
@@ -86,7 +87,7 @@ limitations under the License.
|
||||
}
|
||||
{{ getTaskStatus(session) | uiLabel }}
|
||||
</span>
|
||||
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" />
|
||||
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" />
|
||||
<div class="item-header-actions">
|
||||
@if (getDeviceSerial(session)) {
|
||||
<span class="item-device" [title]="'Device: ' + getDeviceSerial(session)">
|
||||
@@ -586,7 +587,7 @@ limitations under the License.
|
||||
result rows close the block (see check-result below). -->
|
||||
@if (isCheckerBlock(block)) {
|
||||
@if (block.data.phase === 'outcome' && block.data.test_result) {
|
||||
<app-test-result-card [result]="block.data.test_result" />
|
||||
<app-test-result-card [result]="agentService.currentSession()?.test_result || block.data.test_result" [sessionId]="agentService.currentSessionId()" [executionStatus]="agentService.currentSession()?.status || ''" (reviewSaved)="agentService.applyCaseReview($event)" />
|
||||
} @else {
|
||||
<div class="check-lane" [ngClass]="'tone-' + getCheckerTone(block)">
|
||||
<div class="check-header">
|
||||
|
||||
@@ -3702,9 +3702,12 @@ $stream-row-height: 25px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 10.5px;
|
||||
box-sizing: border-box;
|
||||
height: 20px;
|
||||
line-height: 14px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 5px;
|
||||
padding: 0 5px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.05em;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
@@ -27,6 +27,7 @@ import { AgentService, GuidanceEvent, StartupProgressEvent } from '../../service
|
||||
import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model';
|
||||
import { TestResultBadgeComponent, TEST_RESULT_LABELS } from '../test-result-badge/test-result-badge.component';
|
||||
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
|
||||
import { TaskBatchFilterComponent } from '../task-batch-filter/task-batch-filter.component';
|
||||
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
|
||||
import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay';
|
||||
import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE } from '../../core/models/stream.model';
|
||||
@@ -239,7 +240,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
|
||||
@Component({
|
||||
selector: 'app-agent-stream',
|
||||
standalone: true,
|
||||
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent],
|
||||
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
|
||||
templateUrl: './agent-stream.component.html',
|
||||
styleUrl: './agent-stream.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -390,7 +391,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
public activeQueue = computed(() => {
|
||||
const list = this.agentService.sessions().filter((s) => {
|
||||
const status = this.getTaskStatus(s);
|
||||
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status);
|
||||
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status) && this.agentService.matchesBatchFilter(s);
|
||||
});
|
||||
return list.sort((a, b) => {
|
||||
const statusA = this.getTaskStatus(a);
|
||||
@@ -854,6 +855,12 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
// Top Nav Action Methods
|
||||
public openBatchQueue(): void {
|
||||
this.isAutomationOpen.set(false);
|
||||
this.isNotesDropdownOpen.set(false);
|
||||
this.isTaskDropdownOpen.set(true);
|
||||
}
|
||||
|
||||
public toggleTaskDropdown(event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.isTaskDropdownOpen.update(v => !v);
|
||||
|
||||
+5
-2
@@ -5,6 +5,7 @@
|
||||
@if (error()) { <p class="error" role="alert">{{error()}}</p> }
|
||||
@if (notice()) { <p class="notice" role="status">{{notice()}}</p> }
|
||||
@if (tab() === 'reports') {
|
||||
@if(batch(); as b){<button type="button" class="back-to-batch" (click)="tab.set('batches')">返回批次:{{b.name}}</button>}
|
||||
<section><h3>默认报告格式</h3><p>任务结束后自动生成。批次报告包同时包含汇总和每个任务的报告。</p>
|
||||
<div class="format-options"><label><input type="checkbox" [(ngModel)]="word"> Word 文档</label>
|
||||
<label><input type="checkbox" [(ngModel)]="excel"> Excel 表格</label></div>
|
||||
@@ -15,7 +16,7 @@
|
||||
<div class="report-overview">
|
||||
<strong class="report-title">{{r.name}}</strong>
|
||||
<div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><app-test-result-badge [result]="r.test_result" [executionStatus]="r.status" /></div>
|
||||
@if(r.test_result; as result){<app-test-result-card [result]="result" />}
|
||||
@if(r.test_result; as result){<app-test-result-card [result]="result" [sessionId]="r.id" [executionStatus]="r.status" (reviewSaved)="reviewSaved($event)" />}
|
||||
@if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> }
|
||||
</div>
|
||||
<div class="report-meta"><div><strong>任务目标与预期</strong><p>{{r.goal || '未记录'}}</p></div>@if(r.expected){<div><strong>预期结果</strong><p>{{r.expected}}</p></div>}</div>
|
||||
@@ -84,6 +85,8 @@
|
||||
@if(batch(); as b){<section><h3>{{b.name}}</h3><p>{{label(b.status)}} · {{b.reason}}</p>
|
||||
<p class="execution-summary">{{b.case_count || b.jobs?.length || 0}} 个用例 · {{b.repeat_count || 1}} 轮 · 共 {{b.total_executions || b.jobs?.length || 0}} 次执行</p>
|
||||
<div class="actions">
|
||||
<button type="button" (click)="showBatchQueue(b.id)">在任务队列查看此批次</button>
|
||||
@if(agent.currentSessionId()){<button type="button" (click)="tab.set('reports')">查看当前任务报告</button>}
|
||||
@if(b.status === 'running'){<button [disabled]="busy()" (click)="batchAction('pause')">暂停后续任务</button>}
|
||||
@if(b.status === 'draft' || b.status === 'paused'){<button [disabled]="busy()" (click)="batchAction('resume')">{{b.status === 'draft' ? '开始执行' : '继续未开始任务'}}</button>}
|
||||
@if(b.status !== 'running' && b.status !== 'deleting'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
|
||||
@@ -103,6 +106,6 @@
|
||||
</div>
|
||||
}
|
||||
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
|
||||
@for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true); tab.set('reports')" [title]="j.case.goal">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button><span>{{label(j.status)}}</span><app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" />@if(j.error){<small>{{j.error}}</small>}</div>}
|
||||
@for(j of b.jobs; track j.session_id){<div class="job" [class.selected]="agent.currentSessionId() === j.session_id"><button (click)="openBatchTask(j.session_id)" [title]="j.case.goal" [attr.aria-pressed]="agent.currentSessionId() === j.session_id">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button><span>{{label(j.status)}}</span><app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />@if(j.error){<small>{{j.error}}</small>}</div>}
|
||||
</section>}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
:host { display:block;min-width:0;color:#1e293b;font-size:13px;line-height:1.65; }
|
||||
.back-to-batch{margin-bottom:12px;max-width:100%;text-align:left;overflow-wrap:anywhere}
|
||||
.job.selected{background:#eff6ff;border-radius:8px;padding-left:8px;padding-right:8px}.job.selected button{color:#1d4ed8;border-color:#93c5fd;background:#fff}
|
||||
.tabs {display:flex;gap:4px;padding:4px;margin-bottom:18px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:20px;
|
||||
button {flex:1;padding:7px 8px;border:0;border-radius:16px;color:#475569;background:transparent;white-space:nowrap;
|
||||
&.selected {color:#2563eb;background:#fff;box-shadow:0 1px 4px #0f172a12;} }
|
||||
|
||||
+26
-1
@@ -12,7 +12,9 @@ describe('AutomationPanelComponent batch deletion', () => {
|
||||
currentSessionId: signal<string | null>(null),
|
||||
runningSessionId: signal<string | null>(null),
|
||||
agentStatus: signal('idle'),
|
||||
sessions: signal<any[]>([])
|
||||
sessions: signal<any[]>([]),
|
||||
resultRevision: signal(0), batchFilter: signal(''), activeTab: signal('automation'),
|
||||
selectSession: jasmine.createSpy('selectSession'), applyCaseReview: jasmine.createSpy('applyCaseReview')
|
||||
};
|
||||
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['get', 'post', 'delete']);
|
||||
|
||||
@@ -23,6 +25,7 @@ describe('AutomationPanelComponent batch deletion', () => {
|
||||
http.post.and.returnValue(of({ status: 'ready', stage: '报告包已就绪', completed: 0, total: 0, elapsed_seconds: 0, eta_seconds: null, error: null }));
|
||||
http.delete.calls.reset();
|
||||
http.post.calls.reset();
|
||||
agent.currentSessionId.set(null); agent.activeTab.set('automation'); agent.batchFilter.set(''); agent.selectSession.calls.reset();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AutomationPanelComponent],
|
||||
providers: [
|
||||
@@ -117,6 +120,28 @@ describe('AutomationPanelComponent batch deletion', () => {
|
||||
expect((fixture.nativeElement.querySelector('.delete-batch') as HTMLButtonElement).disabled).toBeTrue();
|
||||
});
|
||||
|
||||
it('查看同批次用例时保留批次列表,可直接筛选该批次队列', () => {
|
||||
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||
const component = fixture.componentInstance;
|
||||
component.tab.set('batches');
|
||||
component.batch.set({id:'batch-1',name:'冒烟批次',status:'completed',jobs:[
|
||||
{session_id:'task-a',status:'completed',case:{name:'用例 A',goal:'观察设置'}},
|
||||
{session_id:'task-b',status:'completed',case:{name:'用例 B',goal:'观察主页'}}
|
||||
]});
|
||||
fixture.detectChanges();
|
||||
const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job button')];
|
||||
jobs[0].click(); jobs[1].click();
|
||||
expect(agent.selectSession.calls.allArgs()).toEqual([['task-a',true],['task-b',true]]);
|
||||
expect(component.tab()).toBe('batches');
|
||||
expect(fixture.nativeElement.querySelectorAll('.job').length).toBe(2);
|
||||
const queueRequested = jasmine.createSpy('queueRequested');
|
||||
component.queueRequested.subscribe(queueRequested);
|
||||
component.showBatchQueue('batch-1');
|
||||
expect(agent.batchFilter()).toBe('batch-1'); expect(agent.activeTab()).toBe('tasks');
|
||||
expect(queueRequested).toHaveBeenCalled();
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('shows the failure reason and renders Chinese report structure with line breaks', () => {
|
||||
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core';
|
||||
import { Component, DestroyRef, computed, effect, inject, output, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
@@ -8,6 +8,7 @@ import { SystemService } from '../../services/system.service';
|
||||
import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
|
||||
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
||||
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
|
||||
import { CaseReviewResponse } from '../../core/models/session.model';
|
||||
|
||||
interface ArchiveProgress {
|
||||
status: 'idle' | 'preparing' | 'ready' | 'failed';
|
||||
@@ -24,6 +25,7 @@ interface ArchiveProgress {
|
||||
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
|
||||
})
|
||||
export class AutomationPanelComponent {
|
||||
queueRequested = output<void>();
|
||||
agent = inject(AgentService); system = inject(SystemService); http = inject(HttpClient);
|
||||
tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false);
|
||||
batches = signal<any[]>([]); preview = signal<any>(null);
|
||||
@@ -50,6 +52,7 @@ export class AutomationPanelComponent {
|
||||
const timer = setInterval(() => void this.refresh(), 5000);
|
||||
destroy.onDestroy(() => { this.destroyed = true; clearInterval(timer); });
|
||||
effect(() => {
|
||||
this.agent.resultRevision();
|
||||
const sid = this.agent.currentSessionId(); this.report.set(null); this.guidance = '';
|
||||
if (sid) this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`).subscribe({ next: r => { if (this.agent.currentSessionId() === sid) this.report.set(r); }, error: () => {} });
|
||||
});
|
||||
@@ -57,6 +60,9 @@ export class AutomationPanelComponent {
|
||||
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
|
||||
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告',deleting:'删除中,可重试清理'} as Record<string,string>)[s] || s; }
|
||||
renderReport(content: string) { return renderMarkdownToHtml(content || ''); }
|
||||
reviewSaved(review: CaseReviewResponse) { this.agent.applyCaseReview(review); if (this.report()?.id === review.id) this.report.update(value => ({...value, ...review})); }
|
||||
openBatchTask(id: string) { this.agent.selectSession(id, true); }
|
||||
showBatchQueue(id: string) { this.agent.batchFilter.set(id); this.agent.activeTab.set('tasks'); this.queueRequested.emit(); }
|
||||
duration(seconds: number) { const minutes = Math.floor(seconds / 60); return minutes ? `${minutes} 分 ${seconds % 60} 秒` : `${seconds} 秒`; }
|
||||
downloadBatchArchive(id: string) { void this.prepareBatchArchive(id); }
|
||||
private async prepareBatchArchive(id: string) {
|
||||
|
||||
@@ -58,6 +58,7 @@ limitations under the License.
|
||||
@if (agentService.activeTab() === 'automation') { <app-automation-panel /> }
|
||||
<!-- Tasks Tab Content -->
|
||||
@if (agentService.activeTab() === 'tasks') {
|
||||
<app-task-batch-filter />
|
||||
<!-- Active Queue Section -->
|
||||
<div class="queue-section-header">
|
||||
<span class="material-symbols-outlined section-icon">bolt</span> 当前队列 ({{ activeQueue().length }})
|
||||
@@ -82,7 +83,7 @@ limitations under the License.
|
||||
}
|
||||
{{ getTaskStatus(session) | uiLabel }}
|
||||
</span>
|
||||
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" />
|
||||
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" />
|
||||
<div class="task-card-header-actions">
|
||||
@if (getDeviceSerial(session)) {
|
||||
<span class="task-device" [title]="'Device: ' + getDeviceSerial(session)">
|
||||
@@ -120,7 +121,7 @@ limitations under the License.
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-goal" title="{{ session.initial_goal }}">
|
||||
{{ session.initial_goal || '未命名任务' }}
|
||||
{{ session.display_name || session.initial_goal || '未命名任务' }}
|
||||
</div>
|
||||
<div class="task-card-footer">
|
||||
<app-task-id-copy [taskId]="session.session_id" />
|
||||
|
||||
@@ -338,9 +338,12 @@ $text-muted: #a1a1aa;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 10.5px;
|
||||
box-sizing: border-box;
|
||||
height: 20px;
|
||||
line-height: 14px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 5px;
|
||||
padding: 0 5px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.05em;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
@@ -24,6 +24,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { Session } from '../../core/models/session.model';
|
||||
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
||||
import { TaskBatchFilterComponent } from '../task-batch-filter/task-batch-filter.component';
|
||||
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
|
||||
import { parseNote, parseNoteLines } from '../../utils/markdown-parser.util';
|
||||
|
||||
@@ -32,7 +33,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
|
||||
@Component({
|
||||
selector: 'app-chat-interface',
|
||||
standalone: true,
|
||||
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent, TestResultBadgeComponent],
|
||||
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent, TestResultBadgeComponent, TaskBatchFilterComponent],
|
||||
templateUrl: './chat-interface.component.html',
|
||||
styleUrl: './chat-interface.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -55,7 +56,7 @@ export class ChatInterfaceComponent {
|
||||
public activeQueue = computed(() => {
|
||||
const list = this.agentService.sessions().filter((s) => {
|
||||
const status = this.getTaskStatus(s);
|
||||
return status === 'running' || status === 'paused' || status === 'pending';
|
||||
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status) && this.agentService.matchesBatchFilter(s);
|
||||
});
|
||||
return list.sort((a, b) => {
|
||||
const statusA = this.getTaskStatus(a);
|
||||
|
||||
@@ -43,6 +43,17 @@
|
||||
border-color: rgba(203, 213, 225, 0.9);
|
||||
}
|
||||
|
||||
.app-version {
|
||||
padding: 0 10px 0 8px;
|
||||
border-left: 1px solid #e2e8f0;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.nav-tab-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { version } from '../../../../package.json';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nav-switcher',
|
||||
@@ -43,9 +44,12 @@ import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
<span class="material-symbols-outlined tab-icon">space_dashboard</span>
|
||||
<span class="tab-label">工作区</span>
|
||||
</a>
|
||||
<span class="app-version" [title]="'ARTEMIS 版本 ' + version" [attr.aria-label]="'ARTEMIS 版本 ' + version">v{{version}}</span>
|
||||
</nav>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
styleUrls: ['./nav-switcher.component.scss']
|
||||
})
|
||||
export class NavSwitcherComponent {}
|
||||
export class NavSwitcherComponent {
|
||||
readonly version = version;
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { TaskBatchFilterComponent } from './task-batch-filter.component';
|
||||
|
||||
describe('队列批次筛选', () => {
|
||||
it('支持全部批量、单独任务及具体批次,可清除筛选', fakeAsync(() => {
|
||||
const agent = {batchFilter:signal('batch-1')};
|
||||
TestBed.configureTestingModule({providers:[provideHttpClient(),provideHttpClientTesting(),{provide:AgentService,useValue:agent}]});
|
||||
const fixture = TestBed.createComponent(TaskBatchFilterComponent);
|
||||
const http = TestBed.inject(HttpTestingController);
|
||||
http.expectOne('/api/automation/batches').flush([{id:'batch-1',name:'第一轮冒烟',created_at:100},{id:'batch-2',name:'第二轮冒烟',created_at:200}]);
|
||||
fixture.detectChanges(); tick(); fixture.detectChanges();
|
||||
const select: HTMLSelectElement = fixture.nativeElement.querySelector('select');
|
||||
expect(select.value).toBe('batch-1'); expect(select.options.length).toBe(5);
|
||||
for(const value of ['__batch__','__single__','batch-2']){
|
||||
select.value=value; select.dispatchEvent(new Event('change')); tick(); fixture.detectChanges();
|
||||
expect(agent.batchFilter()).toBe(value);
|
||||
}
|
||||
fixture.nativeElement.querySelector('button').click(); fixture.detectChanges();
|
||||
expect(agent.batchFilter()).toBe('');
|
||||
fixture.destroy(); http.verify();
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Component, DestroyRef, inject, signal } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-task-batch-filter', standalone: true, imports: [FormsModule, DatePipe],
|
||||
template: `<div class="batch-filter"><label>任务范围<select aria-label="按批次筛选任务" [ngModel]="agent.batchFilter()" (ngModelChange)="agent.batchFilter.set($event)">
|
||||
<option value="">全部任务</option><option value="__batch__">全部批量任务</option><option value="__single__">单独任务</option>
|
||||
@for(batch of batches(); track batch.id){<option [value]="batch.id">{{batch.name}} · {{batch.created_at * 1000 | date:'MM-dd HH:mm'}}</option>}
|
||||
@if(agent.batchFilter() && !['__batch__','__single__'].includes(agent.batchFilter()) && !hasSelectedBatch()){<option [value]="agent.batchFilter()">当前选定批次</option>}
|
||||
</select></label>@if(agent.batchFilter()){<button type="button" (click)="agent.batchFilter.set('')">清除筛选</button>}</div>
|
||||
@if(error()){<div class="filter-error" role="alert">{{error()}} <button type="button" (click)="load()">重试</button></div>}`,
|
||||
styles: [`:host{display:block;margin:0 0 12px}.batch-filter{display:flex;align-items:flex-end;gap:8px;color:#475569;font-size:12px}label{flex:1;min-width:0}select{display:block;box-sizing:border-box;width:100%;height:34px;margin-top:5px;border:1px solid #cbd5e1;border-radius:7px;padding:0 9px;background:#fff;color:#1e293b;font:inherit}option{background:#fff;color:#1e293b}button{height:34px;white-space:nowrap;border:1px solid #cbd5e1;border-radius:7px;padding:0 9px;background:#fff;color:#475569;font:inherit;cursor:pointer}select:focus-visible,button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}.filter-error{font-size:12px;color:#b91c1c;margin-top:5px}.filter-error button{height:auto;padding:2px 6px}`]
|
||||
})
|
||||
export class TaskBatchFilterComponent {
|
||||
agent = inject(AgentService); private http = inject(HttpClient);
|
||||
batches = signal<{id: string; name: string; created_at: number}[]>([]); error = signal('');
|
||||
private destroyed = false;
|
||||
constructor() {
|
||||
this.load();
|
||||
const timer = setInterval(() => this.load(), 15000);
|
||||
inject(DestroyRef).onDestroy(() => { this.destroyed = true; clearInterval(timer); });
|
||||
}
|
||||
hasSelectedBatch() { return this.batches().some(batch => batch.id === this.agent.batchFilter()); }
|
||||
load() {
|
||||
this.http.get<{id: string; name: string; created_at: number}[]>('/api/automation/batches').subscribe({
|
||||
next: batches => { if (!this.destroyed) { this.batches.set(batches); this.error.set(''); } },
|
||||
error: () => { if (!this.destroyed) this.error.set('批次列表加载失败'); }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,27 @@ describe('历史任务检索', () => {
|
||||
let http: HttpTestingController;
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({providers: [provideHttpClient(), provideHttpClientTesting(),
|
||||
{provide: AgentService, useValue: {currentSessionId: signal(null), selectSession: jasmine.createSpy()}}]});
|
||||
{provide: AgentService, useValue: {currentSessionId: signal(null), batchFilter: signal(''), resultRevision: signal(0), selectSession: jasmine.createSpy()}}]});
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
afterEach(() => http.verify());
|
||||
|
||||
it('更换批次时保留搜索条件并回到第一页,复验后立即刷新', () => {
|
||||
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.query = '设置'; fixture.componentInstance.page = 3;
|
||||
const agent = TestBed.inject(AgentService);
|
||||
agent.batchFilter.set('batch-2'); fixture.detectChanges();
|
||||
const filtered = http.expectOne(r => r.params.get('batch_id') === 'batch-2');
|
||||
expect(filtered.request.params.get('page')).toBe('1');
|
||||
expect(filtered.request.params.get('q')).toBe('设置');
|
||||
filtered.flush({total:0,items:[]});
|
||||
agent.resultRevision.update(value => value + 1); fixture.detectChanges();
|
||||
http.expectOne(r => r.params.get('batch_id') === 'batch-2').flush({total:0,items:[]});
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('执行完成的任务仍能显示测试不通过,旧任务不会自动显示通过', () => {
|
||||
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||
http.expectOne(r => r.url === '/api/automation/history').flush({total: 3, items: [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
||||
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
||||
import { Component, DestroyRef, inject, signal } from '@angular/core';
|
||||
import { Component, DestroyRef, effect, inject, signal, untracked } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
@@ -14,18 +14,29 @@ import { AgentService } from '../../services/agent.service';
|
||||
@if(error()){<p role="alert">{{error()}}</p>}
|
||||
@if(loading()){<small>正在查询…</small>}
|
||||
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
|
||||
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
|
||||
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
|
||||
</details>}
|
||||
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
|
||||
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
|
||||
</section>`,
|
||||
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-block;border-radius:5px;padding:2px 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
|
||||
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;line-height:14px;border-radius:5px;padding:0 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
|
||||
})
|
||||
export class TaskHistoryComponent {
|
||||
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
|
||||
total=signal(0);groups=signal<any[]>([]);loading=signal(false);error=signal('');collapsed=new Set<string>();
|
||||
private revision=0;private debounce: ReturnType<typeof setTimeout>|undefined;
|
||||
constructor(){this.load();const timer=setInterval(()=>this.load(),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});}
|
||||
constructor(){
|
||||
let filter=this.agent.batchFilter(), resultRevision=this.agent.resultRevision();
|
||||
this.load();
|
||||
effect(()=>{
|
||||
const nextFilter=this.agent.batchFilter(), nextRevision=this.agent.resultRevision();
|
||||
if(nextFilter!==filter || nextRevision!==resultRevision){
|
||||
if(nextFilter!==filter){this.page=1;clearTimeout(this.debounce);}
|
||||
filter=nextFilter;resultRevision=nextRevision;untracked(()=>this.load());
|
||||
}
|
||||
});
|
||||
const timer=setInterval(()=>this.load(),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});
|
||||
}
|
||||
remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});}
|
||||
pages(){return Math.max(1,Math.ceil(this.total()/30));}
|
||||
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已终止',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
|
||||
@@ -35,6 +46,7 @@ export class TaskHistoryComponent {
|
||||
load(){
|
||||
const revision=++this.revision;this.loading.set(true);this.error.set('');
|
||||
let params=new HttpParams().set('q',this.query).set('page',this.page).set('status',this.status);
|
||||
if(this.agent.batchFilter())params=params.set('batch_id',this.agent.batchFilter());
|
||||
if(this.start)params=params.set('start',new Date(this.start+'T00:00:00').getTime()/1000);
|
||||
if(this.end){const d=new Date(this.end+'T00:00:00');d.setDate(d.getDate()+1);params=params.set('end',d.getTime()/1000);}
|
||||
this.http.get<any>('/api/automation/history',{params}).subscribe({next:r=>{
|
||||
|
||||
+7
-2
@@ -8,7 +8,7 @@ export const TEST_RESULT_LABELS: Record<CaseTestResult['status'], string> = {
|
||||
@Component({
|
||||
selector: 'app-test-result-badge',
|
||||
standalone: true,
|
||||
template: `<span [class]="'test-result-badge ' + status()" [class.prominent]="prominent()" [title]="result()?.reason || '执行状态与测试结论分别记录'"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`,
|
||||
template: `<span [class]="'test-result-badge ' + status()" [class.compact]="compact()" [class.prominent]="prominent()" [title]="(result()?.source === 'human' ? '人工确认:' : 'AI确认:') + (result()?.reason || '执行状态与测试结论分别记录')"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`,
|
||||
styles: [`
|
||||
:host{display:inline-flex;vertical-align:middle;max-width:100%}
|
||||
.test-result-badge{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;border-radius:6px;border:1px solid #cbd5e1;padding:3px 8px;font-size:12px;font-weight:700;line-height:1.5;background:#f1f5f9;color:#475569}
|
||||
@@ -17,6 +17,8 @@ export const TEST_RESULT_LABELS: Record<CaseTestResult['status'], string> = {
|
||||
.failed{background:#b91c1c;color:#fff;border-color:#b91c1c}
|
||||
.inconclusive{background:#fff7ed;color:#9a3412;border-color:#fdba74}
|
||||
.blocked{background:#fef3c7;color:#92400e;border-color:#fbbf24}
|
||||
.compact{box-sizing:border-box;height:20px;padding:0 6px;font-size:11px;line-height:14px;gap:3px;border-radius:5px}
|
||||
.compact .material-symbols-outlined{font-size:13px}
|
||||
.prominent{padding:4px 9px;font-size:14px;gap:5px}
|
||||
.prominent .material-symbols-outlined{font-size:18px}
|
||||
`]
|
||||
@@ -24,8 +26,11 @@ export const TEST_RESULT_LABELS: Record<CaseTestResult['status'], string> = {
|
||||
export class TestResultBadgeComponent {
|
||||
result = input<CaseTestResult | null | undefined>();
|
||||
executionStatus = input('');
|
||||
compact = input(false);
|
||||
prominent = input(false);
|
||||
status = computed(() => this.result()?.status || (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.executionStatus()) ? 'inconclusive' : 'pending'));
|
||||
label = computed(() => this.status() === 'pending' ? '待判定' : TEST_RESULT_LABELS[this.status() as CaseTestResult['status']]);
|
||||
label = computed(() => this.result()?.source === 'human'
|
||||
? (this.status() === 'passed' ? '人工通过' : '人工不通过')
|
||||
: this.status() === 'pending' ? '待判定' : TEST_RESULT_LABELS[this.status() as CaseTestResult['status']]);
|
||||
icon = computed(() => ({passed: 'check_circle', failed: 'cancel', blocked: 'block', inconclusive: 'help', pending: 'hourglass_empty'})[this.status()]);
|
||||
}
|
||||
+46
@@ -1,7 +1,11 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestResultCardComponent } from './test-result-card.component';
|
||||
|
||||
describe('用例测试结果卡片', () => {
|
||||
beforeEach(() => TestBed.configureTestingModule({providers:[provideHttpClient(), provideHttpClientTesting()]}));
|
||||
afterEach(() => TestBed.inject(HttpTestingController).verify());
|
||||
it('显式展示不通过及原因,允许展开证据', () => {
|
||||
const fixture = TestBed.createComponent(TestResultCardComponent);
|
||||
fixture.componentRef.setInput('result', {status: 'failed', reason: '预期英文,实际为中文。', evidence: ['截图显示搜索设置']});
|
||||
@@ -16,4 +20,46 @@ describe('用例测试结果卡片', () => {
|
||||
expect(details.open).toBeTrue();
|
||||
expect(details.textContent).toContain('截图显示搜索设置');
|
||||
});
|
||||
|
||||
it('人工复验可覆盖 AI 判定,保留原始结论并提交备注', () => {
|
||||
const fixture = TestBed.createComponent(TestResultCardComponent);
|
||||
const ai = {status:'failed',reason:'AI发现不一致',evidence:['步骤 3 截图'],source:'ai'};
|
||||
fixture.componentRef.setInput('result', ai);
|
||||
fixture.componentRef.setInput('sessionId', 'task-1'); fixture.componentRef.setInput('executionStatus','completed');
|
||||
const saved = jasmine.createSpy(); fixture.componentInstance.reviewSaved.subscribe(saved);
|
||||
fixture.detectChanges();
|
||||
fixture.nativeElement.querySelector('.review-toolbar button').click(); fixture.detectChanges();
|
||||
fixture.componentInstance.note = '实际符合预期,已人工核对';
|
||||
fixture.nativeElement.querySelector('.confirm-pass').click(); fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.confirm-fail').disabled).toBeTrue();
|
||||
const request = TestBed.inject(HttpTestingController).expectOne('/api/automation/reports/task-1/review');
|
||||
expect(request.request.method).toBe('PUT');
|
||||
expect(request.request.body).toEqual({status:'passed',reason:'实际符合预期,已人工核对'});
|
||||
const manual = {status:'passed',reason:'实际符合预期,已人工核对',evidence:[],source:'human',reviewed_at:100,ai_result:ai};
|
||||
request.flush({id:'task-1',test_result:manual,verdict:'测试通过(人工确认)'});
|
||||
fixture.componentRef.setInput('result',manual); fixture.detectChanges();
|
||||
expect(saved).toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain('人工通过');
|
||||
expect(fixture.nativeElement.textContent).toContain('原 AI 结论');
|
||||
expect(fixture.nativeElement.textContent).toContain('AI发现不一致');
|
||||
expect(fixture.nativeElement.querySelector('.review-form')).toBeNull();
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('保存失败时保留备注并允许重试,执行中不显示人工复验', () => {
|
||||
const fixture = TestBed.createComponent(TestResultCardComponent);
|
||||
fixture.componentRef.setInput('result',{status:'inconclusive',reason:'尚未验证',evidence:[]});
|
||||
fixture.componentRef.setInput('sessionId','task-2'); fixture.componentRef.setInput('executionStatus','running');
|
||||
fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.review-toolbar')).toBeNull();
|
||||
fixture.componentRef.setInput('executionStatus','failed'); fixture.detectChanges();
|
||||
fixture.nativeElement.querySelector('.review-toolbar button').click(); fixture.detectChanges();
|
||||
fixture.componentInstance.note = '人工发现异常';
|
||||
fixture.nativeElement.querySelector('.confirm-fail').click();
|
||||
TestBed.inject(HttpTestingController).expectOne('/api/automation/reports/task-2/review').flush({detail:'报告正在打包'}, {status:409,statusText:'Conflict'});
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.note).toBe('人工发现异常');
|
||||
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain('报告正在打包');
|
||||
expect(fixture.nativeElement.querySelector('.confirm-fail').disabled).toBeFalse();
|
||||
fixture.destroy();
|
||||
});
|
||||
});
|
||||
+49
-4
@@ -1,15 +1,30 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { CaseTestResult } from '../../core/models/session.model';
|
||||
import { Component, computed, effect, inject, input, output, signal } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { CaseReviewResponse, CaseTestResult } from '../../core/models/session.model';
|
||||
import { TestResultBadgeComponent } from './test-result-badge.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-test-result-card',
|
||||
standalone: true,
|
||||
imports: [TestResultBadgeComponent],
|
||||
imports: [TestResultBadgeComponent, DatePipe, FormsModule],
|
||||
template: `<section [class]="'test-result-card ' + result().status" aria-label="用例测试结果" role="status">
|
||||
<header><span class="caption">用例测试结果</span><app-test-result-badge [result]="result()" [prominent]="true" /></header>
|
||||
<header><span class="caption">用例测试结果 <small>{{result().source === 'human' ? '人工确认' : 'AI确认'}}</small></span><app-test-result-badge [result]="result()" [prominent]="true" /></header>
|
||||
<p class="reason">{{result().reason}}</p>
|
||||
@if(result().reviewed_at){<p class="review-time">复验于 {{result().reviewed_at! * 1000 | date:'yyyy-MM-dd HH:mm:ss'}}</p>}
|
||||
@if(result().ai_result; as original){<details><summary>原 AI 结论 · {{original.status === 'passed' ? '测试通过' : original.status === 'failed' ? '测试不通过' : original.status === 'blocked' ? '测试阻塞' : '未验证'}}</summary><p class="reason">{{original.reason}}</p>@if(original.evidence.length){<ol>@for(item of original.evidence; track $index){<li>{{item}}</li>}</ol>}</details>}
|
||||
@if(result().evidence.length){<details><summary>查看验收证据 · {{result().evidence.length}} 条</summary><ol>@for(item of result().evidence; track $index){<li>{{item}}</li>}</ol></details>}
|
||||
@if(canReview()){
|
||||
<div class="review-toolbar"><button type="button" [disabled]="saving()" (click)="toggleReview()" [attr.aria-expanded]="reviewing()">{{result().source === 'human' ? '修改人工复验' : '人工复验'}}</button>@if(notice()){<span role="status">{{notice()}}</span>}</div>
|
||||
@if(reviewing()){
|
||||
<div class="review-form" aria-label="人工复验">
|
||||
<label>复验备注(选填)<textarea [(ngModel)]="note" [disabled]="saving()" rows="2" maxlength="2000" placeholder="填写人工检查依据或发现的问题"></textarea></label>
|
||||
<div class="review-actions"><button type="button" class="confirm-pass" [disabled]="saving()" (click)="saveReview('passed')">确认通过</button><button type="button" class="confirm-fail" [disabled]="saving()" (click)="saveReview('failed')">确认不通过</button><button type="button" [disabled]="saving()" (click)="reviewing.set(false)">取消</button>@if(saving()){<span role="status">正在保存…</span>}</div>
|
||||
@if(error()){<p class="review-error" role="alert">{{error()}}</p>}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</section>`,
|
||||
styles: [`
|
||||
:host{display:block;margin:8px 0}
|
||||
@@ -19,13 +34,43 @@ import { TestResultBadgeComponent } from './test-result-badge.component';
|
||||
.inconclusive,.blocked{border-color:#fde68a;border-left-color:#b45309;background:#fffbeb}
|
||||
header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}
|
||||
.caption{font-size:12px;font-weight:600;color:#475569}
|
||||
.caption small{font-size:11px;font-weight:400;margin-left:7px;color:#64748b}
|
||||
.reason{font-size:13px;line-height:1.7;margin:8px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;color:#1e293b}
|
||||
details{margin-top:6px;font-size:12px;line-height:1.7;color:#475569}
|
||||
summary{cursor:pointer;width:fit-content;padding:2px 0;font-weight:600}
|
||||
ol{padding-left:22px;margin:8px 0 0}li{padding:3px 0;white-space:pre-wrap;overflow-wrap:anywhere}
|
||||
.review-time{margin:5px 0 0;color:#64748b;font-size:11px}
|
||||
.review-toolbar,.review-actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px;font-size:12px}
|
||||
button{border:1px solid #cbd5e1;border-radius:6px;padding:4px 9px;background:#fff;color:#475569;font:inherit;cursor:pointer;line-height:1.5}
|
||||
button:hover{border-color:#94a3b8;background:#f8fafc}button:disabled{opacity:.55;cursor:wait}button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}
|
||||
.review-toolbar span{color:#15803d}.review-form{border-top:1px solid #cbd5e1;margin-top:10px;padding-top:10px;font-size:12px}
|
||||
label{display:block;color:#475569}textarea{display:block;box-sizing:border-box;width:100%;resize:vertical;border:1px solid #cbd5e1;border-radius:6px;margin-top:5px;padding:7px 9px;color:#1e293b;background:#fff;font:inherit;line-height:1.6}
|
||||
.confirm-pass{color:#166534;border-color:#86efac;background:#f0fdf4}.confirm-fail{color:#991b1b;border-color:#fca5a5;background:#fef2f2}.review-error{color:#b91c1c;margin:8px 0 0}
|
||||
@media(max-width:480px){.test-result-card{padding:10px 12px}}
|
||||
`]
|
||||
})
|
||||
export class TestResultCardComponent {
|
||||
private http = inject(HttpClient);
|
||||
result = input.required<CaseTestResult>();
|
||||
sessionId = input<string | null>(null);
|
||||
executionStatus = input('');
|
||||
reviewSaved = output<CaseReviewResponse>();
|
||||
reviewing = signal(false); saving = signal(false); error = signal(''); notice = signal(''); note = '';
|
||||
canReview = computed(() => !!this.sessionId() && ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.executionStatus()));
|
||||
constructor() {
|
||||
effect(() => { this.sessionId(); this.reviewing.set(false); this.error.set(''); this.notice.set(''); this.note = ''; });
|
||||
}
|
||||
toggleReview() { this.reviewing.update(value => !value); this.error.set(''); this.notice.set(''); this.note = this.result().source === 'human' ? this.result().reason : ''; }
|
||||
saveReview(status: 'passed' | 'failed') {
|
||||
const id = this.sessionId();
|
||||
if (!id || !this.canReview() || this.saving()) return;
|
||||
this.saving.set(true); this.error.set('');
|
||||
this.http.put<CaseReviewResponse>(`/api/automation/reports/${encodeURIComponent(id)}/review`, {status, reason: this.note.trim()}).subscribe({
|
||||
next: response => {
|
||||
this.saving.set(false); this.reviewSaved.emit(response);
|
||||
if (this.sessionId() === id) { this.reviewing.set(false); this.note = ''; this.notice.set('人工复验已保存,报告已更新'); }
|
||||
},
|
||||
error: error => { this.saving.set(false); if (this.sessionId() === id) this.error.set(error.error?.detail || '复验保存失败,请重试'); }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,20 @@ export interface CaseTestResult {
|
||||
status: 'passed' | 'failed' | 'inconclusive' | 'blocked';
|
||||
reason: string;
|
||||
evidence: string[];
|
||||
source?: 'ai' | 'human';
|
||||
reviewed_at?: number;
|
||||
ai_result?: CaseTestResult | null;
|
||||
}
|
||||
|
||||
export interface CaseReviewResponse {
|
||||
id: string;
|
||||
test_result: CaseTestResult;
|
||||
verdict: string;
|
||||
}
|
||||
|
||||
export interface TaskQueueItem {
|
||||
session_id: string;
|
||||
batch_id?: string | null;
|
||||
goal: string;
|
||||
profile?: string;
|
||||
status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
|
||||
@@ -40,6 +50,7 @@ export interface TaskQueueItem {
|
||||
|
||||
export interface Session {
|
||||
session_id: string;
|
||||
batch_id?: string | null;
|
||||
initial_goal: string;
|
||||
start_time: number;
|
||||
end_time?: number;
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Injectable, signal, inject, computed, DestroyRef, NgZone } from '@angul
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage } from '../core/models/session.model';
|
||||
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage, CaseReviewResponse } from '../core/models/session.model';
|
||||
import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model';
|
||||
import { StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE, PersistedCheckerStream, StreamSegment } from '../core/models/stream.model';
|
||||
import { extractStepReplayFrames } from '../utils/action-formatter.util';
|
||||
@@ -85,6 +85,23 @@ export class AgentService {
|
||||
private rawSessions = signal<Session[]>([]);
|
||||
private pendingQueue = signal<Session[]>([]);
|
||||
public activeTasks = signal<any[]>([]);
|
||||
public batchFilter = signal('');
|
||||
public resultRevision = signal(0);
|
||||
|
||||
public matchesBatchFilter(session: Session): boolean {
|
||||
const filter = this.batchFilter();
|
||||
if (!filter) return true;
|
||||
if (filter === '__batch__') return !!session.batch_id;
|
||||
if (filter === '__single__') return !session.batch_id;
|
||||
return session.batch_id === filter;
|
||||
}
|
||||
|
||||
public applyCaseReview(review: CaseReviewResponse): void {
|
||||
this.rawSessions.update(items => items.map(session => session.session_id === review.id
|
||||
? {...session, test_result: review.test_result} : session));
|
||||
this.resultRevision.update(value => value + 1);
|
||||
this.fetchSessions();
|
||||
}
|
||||
// Persistent tracking of active/pending sessions across polling boundaries
|
||||
private activeSessionTracking = new Map<string, Session>();
|
||||
|
||||
@@ -129,6 +146,7 @@ export class AgentService {
|
||||
}
|
||||
const finalSession: Session = {
|
||||
...s,
|
||||
batch_id: s.batch_id || activeMatch?.batch_id || pendingMatch?.batch_id || null,
|
||||
status: sStatus,
|
||||
device_serial: serial,
|
||||
model_info: isCurrentActive && this.activeModel() ? this.activeModel()! : s.model_info
|
||||
@@ -149,6 +167,7 @@ export class AgentService {
|
||||
|| !!activeMatch;
|
||||
const finalPending: Session = {
|
||||
...p,
|
||||
batch_id: p.batch_id || activeMatch?.batch_id || null,
|
||||
status: isCurrentRunning ? (activeMatch?.status || (status === 'paused' ? 'paused' : 'running')) : (p.status || 'pending'),
|
||||
device_serial: p.device_serial || p.device_id || activeMatch?.device_id || null
|
||||
};
|
||||
@@ -165,6 +184,8 @@ export class AgentService {
|
||||
if (!existing) {
|
||||
const newSession: Session = {
|
||||
session_id: sid,
|
||||
batch_id: at.batch_id || null,
|
||||
display_name: at.display_name,
|
||||
initial_goal: at.goal || goal || '',
|
||||
start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000),
|
||||
status: at.status || 'running',
|
||||
@@ -1578,6 +1599,8 @@ export class AgentService {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
return {
|
||||
session_id: item.session_id || `pending-task-${index}`,
|
||||
batch_id: item.batch_id || null,
|
||||
display_name: item.display_name,
|
||||
initial_goal: item.goal || '',
|
||||
start_time: item.start_time || item.created_at || (Date.now() / 1000 + index),
|
||||
status: item.status || 'pending',
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022"
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
|
||||
报告由项目自己的 `python-docx`、`openpyxl` 生成,不依赖 Codex 客户端的私有工具。输出保存在 `artifacts/reports/`。
|
||||
|
||||
任务结束后,可在“用例测试结果”中点击“人工复验”,确认通过或不通过,备注选填。复验会保留原 AI 结论和历次复验记录,不改变任务的执行状态。任务队列、单任务报告和批次汇总采用最近一次人工结论,标明“人工确认”;未复验的结论标明“AI确认”。复验后下载的报告包会重新生成。
|
||||
|
||||
任务队列的“任务范围”可筛选全部批量任务、单独任务或指定批次,日期、搜索和状态筛选可同时使用。点击批次内的用例会在左侧打开执行记录,右侧保留批次列表;可用“在任务队列查看此批次”集中查看,进入单任务报告后也可返回原批次。
|
||||
|
||||
## Excel 批量导入
|
||||
|
||||
1. 点击“下载 Excel 模板”,保留“任务”工作表及表头顺序。
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
|
||||
[project]
|
||||
name = "artemis"
|
||||
version = "1.5.0"
|
||||
version = "1.5.1"
|
||||
description = "Autonomous AI Assistant for Mobile Automation"
|
||||
|
||||
authors = [
|
||||
|
||||
+17
-2
@@ -33,10 +33,26 @@ function Get-LaunchText {
|
||||
return $English
|
||||
}
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$RootDir = Split-Path -Parent $ScriptDir
|
||||
$ProjectVersion = 'unknown'
|
||||
try {
|
||||
$inProjectSection = $false
|
||||
foreach ($line in Get-Content -LiteralPath (Join-Path $RootDir 'pyproject.toml') -Encoding UTF8 -ErrorAction Stop) {
|
||||
if ($line -match '^\[project\]\s*$') { $inProjectSection = $true; continue }
|
||||
if ($inProjectSection -and $line -match '^\[') { break }
|
||||
if ($inProjectSection -and $line -match '^version\s*=\s*"([^"]+)"') {
|
||||
$ProjectVersion = $Matches[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
# Keep UTF-8 Chinese text in this BOM-encoded script, never in CMD batch files.
|
||||
# CMD can misparse multibyte batch lines when an archive/editor converts CRLF to LF.
|
||||
if ($Help) {
|
||||
Write-Host (Get-LaunchText 'Usage: start.bat [options]' '用法:start-cn.bat [选项]')
|
||||
Write-Host (Get-LaunchText "Version: v$ProjectVersion" "版本:v$ProjectVersion")
|
||||
Write-Host ''
|
||||
Write-Host (Get-LaunchText ' -SetupOnly Prepare the environment without launching the UI' ' -SetupOnly 安装或检查环境,不启动网页')
|
||||
Write-Host (Get-LaunchText ' -Port 8001 Use the specified port (default: 8000)' ' -Port 8001 使用指定端口,默认 8000')
|
||||
@@ -51,6 +67,7 @@ if ($Help) {
|
||||
exit 0
|
||||
}
|
||||
Write-Host '======================================================'
|
||||
Write-Host (Get-LaunchText "Version: v$ProjectVersion" "版本:v$ProjectVersion")
|
||||
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN' -and $Mirror -eq 'China') {
|
||||
Write-Host ' ARTEMIS 中文启动 - 使用国内镜像'
|
||||
Write-Host '======================================================'
|
||||
@@ -67,8 +84,6 @@ try {
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
|
||||
} catch {}
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$RootDir = Split-Path -Parent $ScriptDir
|
||||
Set-Location $RootDir
|
||||
|
||||
# Startup never mutates source; upgrades run from a staged, independent process.
|
||||
|
||||
@@ -25,9 +25,17 @@ NC='\033[0m'
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "${SCRIPT_DIR}"
|
||||
|
||||
ARTEMIS_VERSION="$(awk '
|
||||
/^\[project\]/ { in_project = 1; next }
|
||||
/^\[/ { in_project = 0 }
|
||||
in_project && /^version[[:space:]]*=/ { split($0, fields, "\""); print fields[2]; exit }
|
||||
' "${SCRIPT_DIR}/pyproject.toml" 2>/dev/null || true)"
|
||||
ARTEMIS_VERSION="${ARTEMIS_VERSION:-unknown}"
|
||||
|
||||
echo -e "${BOLD}${CYAN}======================================================${NC}"
|
||||
echo -e "${BOLD}${CYAN} ✨ Artemis Autonomous Mobile Agent UI ${NC}"
|
||||
echo -e "${BOLD}${CYAN}======================================================${NC}"
|
||||
echo -e "${CYAN} Version: v${ARTEMIS_VERSION}${NC}"
|
||||
echo ""
|
||||
|
||||
# 1. Normalize PATH with standard toolchain and package manager paths
|
||||
|
||||
@@ -780,7 +780,7 @@ def test_failed_report_exposes_logged_reason_and_formats_chinese_markdown(isolat
|
||||
)
|
||||
report = reports.session_report(job["session_id"])
|
||||
assert "连续 3 次" in report["error"]
|
||||
assert report["verdict"] == "测试阻塞"
|
||||
assert report["verdict"] == "测试阻塞(AI确认)"
|
||||
assert report["test_result"]["status"] == "blocked"
|
||||
assert "连续 3 次" in report["test_result"]["reason"]
|
||||
assert "abc123secret" not in reports.failure_reason(
|
||||
@@ -844,8 +844,8 @@ def test_prompt_based_verdict_is_consistent_in_history_batch_and_reports(
|
||||
report = client.get(f"/api/automation/reports/{job['session_id']}").json()
|
||||
for item in (history, batch_job, report):
|
||||
assert item["status"] == "completed"
|
||||
assert item["test_result"] == result
|
||||
assert report["verdict"] == "测试不通过"
|
||||
assert item["test_result"] == {**result, "source": "ai"}
|
||||
assert report["verdict"] == "测试不通过(AI确认)"
|
||||
paths = reports.export_session(job["session_id"])
|
||||
doc = Document(next(path for path in paths if path.suffix == ".docx"))
|
||||
doc_text = "\n".join(
|
||||
@@ -856,10 +856,133 @@ def test_prompt_based_verdict_is_consistent_in_history_batch_and_reports(
|
||||
assert result["evidence"][0] in doc_text
|
||||
wb = load_workbook(next(path for path in paths if path.suffix == ".xlsx"))
|
||||
values = [cell.value for row in wb["任务结果"].iter_rows() for cell in row]
|
||||
assert "测试不通过" in values
|
||||
assert "测试不通过(AI确认)" in values
|
||||
assert result["reason"] in values
|
||||
|
||||
|
||||
def test_human_review_overrides_every_report_preserves_ai_and_invalidates_batch_cache(
|
||||
client, isolated, monkeypatch
|
||||
):
|
||||
from artemis.runtime import case_result
|
||||
from apps.admin_console.routers import sessions as sessions_api
|
||||
|
||||
monkeypatch.setattr(case_result, "TRACES_PATH", reports.TRACES_PATH)
|
||||
batch = service.create_batch(payload())
|
||||
jobs = isolated.jobs(batch["id"])
|
||||
for job in jobs:
|
||||
isolated.save_job({**job, "status": "completed", "ended_at": time.time()})
|
||||
session_id = jobs[0]["session_id"]
|
||||
ai = {"status": "failed", "reason": "AI识别到预期不匹配", "evidence": ["步骤 3 截图"]}
|
||||
directory = reports.TRACES_PATH / session_id
|
||||
directory.mkdir(parents=True)
|
||||
outcome_file = directory / "run_outcome.json"
|
||||
original = json.dumps({"task_status": "completed", "test_result": ai}, ensure_ascii=False)
|
||||
outcome_file.write_text(original, encoding="utf-8")
|
||||
archive = reports.export_batch(batch["id"])
|
||||
isolated.put("batch", batch["id"], {**batch, "report_ready": True})
|
||||
|
||||
response = client.put(
|
||||
f"/api/automation/reports/{session_id}/review",
|
||||
json={"status": "passed", "reason": "已人工核对,符合预期"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "completed"
|
||||
assert response.json()["verdict"] == "测试通过(人工确认)"
|
||||
assert outcome_file.read_text(encoding="utf-8") == original
|
||||
assert not archive.exists()
|
||||
assert not isolated.get("batch", batch["id"])["report_ready"]
|
||||
assert len(isolated.get("case_review", session_id)["reviews"]) == 1
|
||||
for result in (
|
||||
response.json(),
|
||||
client.get("/api/automation/history", params={"q": session_id}).json()["items"][0],
|
||||
client.get(f"/api/automation/batches/{batch['id']}").json()["jobs"][0],
|
||||
):
|
||||
assert result["test_result"]["source"] == "human"
|
||||
assert result["test_result"]["status"] == "passed"
|
||||
assert result["test_result"]["ai_result"] == {**ai, "source": "ai"}
|
||||
assert result["test_result"]["reviewed_at"] > 0
|
||||
|
||||
monkeypatch.setattr(sessions_api, "store", isolated)
|
||||
monkeypatch.setattr(sessions_api, "TRACES_PATH", reports.TRACES_PATH)
|
||||
monkeypatch.setattr(
|
||||
sessions_api.session_repo,
|
||||
"get_session_by_id",
|
||||
lambda sid: {"session_id": sid, "status": "completed"},
|
||||
)
|
||||
client.app.include_router(sessions_api.router)
|
||||
detail = client.get(f"/api/sessions/{session_id}").json()
|
||||
assert detail["test_result"]["source"] == "human"
|
||||
assert detail["batch_id"] == batch["id"]
|
||||
|
||||
downloaded = client.get(f"/api/automation/batches/{batch['id']}/archive")
|
||||
assert downloaded.status_code == 200
|
||||
with zipfile.ZipFile(BytesIO(downloaded.content)) as bundle:
|
||||
wb = load_workbook(BytesIO(bundle.read("批次结果.xlsx")))
|
||||
assert wb.active["D2"].value == "测试通过(人工确认)"
|
||||
assert "AI确认" in wb.active["D3"].value
|
||||
doc = Document(BytesIO(bundle.read("批次汇总.docx")))
|
||||
assert doc.tables[0].rows[1].cells[3].text == "测试通过(人工确认)"
|
||||
|
||||
changed = client.put(f"/api/automation/reports/{session_id}/review", json={"status": "failed"})
|
||||
assert changed.status_code == 200
|
||||
assert changed.json()["verdict"] == "测试不通过(人工确认)"
|
||||
assert len(isolated.get("case_review", session_id)["reviews"]) == 2
|
||||
assert outcome_file.read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
def test_review_rejects_active_missing_invalid_and_packing_tasks(client, isolated, monkeypatch):
|
||||
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||
url = f"/api/automation/reports/{job['session_id']}/review"
|
||||
assert client.put(url, json={"status": "passed"}).status_code == 409
|
||||
assert isolated.get("case_review", job["session_id"]) is None
|
||||
assert client.put(url, json={"status": "inconclusive"}).status_code == 422
|
||||
assert client.put(url, json={"status": "passed", "source": "human"}).status_code == 422
|
||||
assert client.put(url, json={"status": "passed", "reason": "字" * 2001}).status_code == 422
|
||||
assert (
|
||||
client.put("/api/automation/reports/missing/review", json={"status": "passed"}).status_code
|
||||
== 404
|
||||
)
|
||||
isolated.save_job({**job, "status": "completed"})
|
||||
monkeypatch.setattr(api.archive_service, "status", lambda *args: {"status": "preparing"})
|
||||
assert client.put(url, json={"status": "passed"}).status_code == 409
|
||||
assert isolated.get("case_review", job["session_id"]) is None
|
||||
|
||||
|
||||
def test_batch_filter_searches_full_history_and_reviews_are_removed_with_batch(client, isolated):
|
||||
first, second = [service.create_batch(payload(name=name)) for name in ("第一批", "第二批")]
|
||||
for batch in (first, second):
|
||||
for job in isolated.jobs(batch["id"]):
|
||||
isolated.save_job({**job, "status": "completed"})
|
||||
with isolated.connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO sessions(session_id,initial_goal,start_time,status) VALUES (?,?,?,?)",
|
||||
("single-case", "单独检查", time.time(), "completed"),
|
||||
)
|
||||
conn.commit()
|
||||
filtered = client.get(
|
||||
"/api/automation/history",
|
||||
params={"batch_id": first["id"], "q": "用例", "size": 1, "page": 2},
|
||||
).json()
|
||||
assert filtered["total"] == 2
|
||||
assert len(filtered["items"]) == 1
|
||||
assert filtered["items"][0]["batch_id"] == first["id"]
|
||||
assert (
|
||||
client.get("/api/automation/history", params={"batch_id": "__batch__"}).json()["total"] == 4
|
||||
)
|
||||
singles = client.get("/api/automation/history", params={"batch_id": "__single__"}).json()
|
||||
assert [item["session_id"] for item in singles["items"]] == ["single-case"]
|
||||
assert client.get("/api/automation/history", params={"batch_id": "bad/path"}).status_code == 400
|
||||
reviewed_id = isolated.jobs(first["id"])[0]["session_id"]
|
||||
isolated.save_case_review(reviewed_id, "passed", "人工核对")
|
||||
isolated.begin_delete_batch(first["id"])
|
||||
isolated.delete_batch(first["id"])
|
||||
assert isolated.get("case_review", reviewed_id) is None
|
||||
assert len(isolated.jobs(second["id"])) == 2
|
||||
isolated.save_case_review("single-case", "failed", "不符合预期")
|
||||
isolated.delete_history("single-case")
|
||||
assert isolated.get("case_review", "single-case") is None
|
||||
|
||||
|
||||
def test_task_material_zip_contains_ordered_evidence_and_reports(
|
||||
client, isolated, tmp_path, monkeypatch
|
||||
):
|
||||
|
||||
Reference in new issue
Block a user