pipeline-service/pipeline_service/test_case_capability.py

200 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""测试用例能力 — sd_test_cases 的执行状态机语义化迁移(通用、产线无关)。
定位:用例 CRUD 走 xls2ui 生成的端点;本模块只做「执行结果状态流转」——
每个迁移 CAS 原子(防并发/越权)+ 租户隔离 + 审计。
用例的执行状态机/流转规则在 test-case skill 里LLM 读 skill 判断合法性);本模块只固化操作原语。
失败用例联动建 Bugcase_id → sd_bugs.case_id是规范见 test-case skill。
角色规范role 参数用 agent.{role}(无前缀自动补 agent.);人角色用 {orgtype}.{role}
scope 约定sd_test_cases 无 project_id/iteration_id 列,用 plan_id 做范围校验CAS 的 WHERE 里带 plan_id
"""
import logging
from sqlor.dbpools import DBPools
from appPublic.uniqueID import getID
from .audit import record_audit
DBNAME = "pipeline"
logger = logging.getLogger("pipeline.test_case_capability")
TABLE = "sd_test_cases"
# 测试用例执行状态SDLC 默认,状态机语义见 test-case skill
S_PENDING = "pending" # 待执行
S_PASS = "pass" # 通过
S_FAIL = "fail" # 失败
S_BLOCKED = "blocked" # 阻塞
S_SKIPPED = "skipped" # 跳过
def _get_db():
db = DBPools()
if not db.databases:
from appPublic.jsonConfig import getConfig
config = getConfig()
if config.databases:
db.databases = config.databases
return db, DBNAME
def _normalize_role(role):
"""角色规范agent 角色补 agent. 前缀;人角色 {orgtype}.{role} 保留原样。"""
role = (role or "").strip()
if not role:
return ""
if "." in role:
return role
return f"agent.{role}"
async def _execute_transition(case_id, plan_id, from_states, to_state, action,
actual_result="", executed_by="", duration_ms=None,
who=None, agent_id=None):
"""执行结果 CAS 迁移 + 审计(记录执行人/实际结果/执行时间)。返回 (ok, message)。"""
if not case_id or not plan_id:
return False, "缺少 case_id 或 plan_id"
if isinstance(from_states, str):
from_states = [from_states]
from_in = ",".join(f"${{s{i}}}$" for i in range(len(from_states)))
params = {"to": to_state, "cid": case_id, "pid": plan_id,
"ar": actual_result or '', "eb": executed_by or ''}
for i, s in enumerate(from_states):
params[f"s{i}"] = s
if duration_ms is not None:
try:
params["dur"] = int(duration_ms)
dur_set = ", duration_ms=${dur}$"
except (TypeError, ValueError):
dur_set = ""
else:
dur_set = ""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
await sor.sqlExe(
f"UPDATE {TABLE} SET status=${{to}}$, actual_result=${{ar}}$, "
f"executed_by=${{eb}}$, executed_at=NOW(){dur_set} "
f"WHERE id=${{cid}}$ AND plan_id=${{pid}}$ AND status IN ({from_in})",
params)
recs = await sor.R(TABLE, {'id': case_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return False, "用例不存在"
cur = getattr(recs[0], 'status', '')
if cur != to_state:
return False, f"状态迁移失败(CAS): 期望 from={from_states} 实际 status={cur}"
await record_audit(plan_id, TABLE, case_id, action,
from_state="|".join(from_states), to_state=to_state,
who=who, agent_id=agent_id, detail=actual_result, sor=sor)
return True, to_state
async def create_case(plan_id, case_name, case_type="functional", priority="P2",
precondition="", steps="", expected_result="",
created_by="", who=None, agent_id=None):
"""创建用例新建记录status=pending。返回 (ok, case_id_or_message)。"""
if not plan_id:
return False, "缺少 plan_id"
if not case_name or not case_name.strip():
return False, "缺少 case_name"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
cid = getID()
await sor.C(TABLE, {
'id': cid,
'plan_id': plan_id,
'case_name': case_name.strip(),
'case_type': case_type or 'functional',
'priority': priority or 'P2',
'precondition': precondition or '',
'steps': steps or '',
'expected_result': expected_result or '',
'status': S_PENDING,
})
await record_audit(plan_id, TABLE, cid, 'create',
to_state=S_PENDING, who=_normalize_role(who),
agent_id=agent_id, sor=sor)
logger.info("create_case: %s plan=%s", cid, plan_id)
return True, cid
async def pass_case(case_id, plan_id, actual_result="", executed_by="",
duration_ms=None, who=None, agent_id=None):
"""用例通过pending/fail → pass。"""
return await _execute_transition(case_id, plan_id, [S_PENDING, S_FAIL], S_PASS, 'pass',
actual_result=actual_result, executed_by=executed_by,
duration_ms=duration_ms, who=who, agent_id=agent_id)
async def fail_case(case_id, plan_id, actual_result="", executed_by="",
duration_ms=None, who=None, agent_id=None):
"""用例失败pending/pass → fail失败应联动建 Bug规范见 test-case skill"""
return await _execute_transition(case_id, plan_id, [S_PENDING, S_PASS], S_FAIL, 'fail',
actual_result=actual_result, executed_by=executed_by,
duration_ms=duration_ms, who=who, agent_id=agent_id)
async def block_case(case_id, plan_id, actual_result="", executed_by="",
who=None, agent_id=None):
"""用例阻塞pending → blocked因环境/依赖无法执行)。"""
return await _execute_transition(case_id, plan_id, [S_PENDING], S_BLOCKED, 'block',
actual_result=actual_result, executed_by=executed_by,
who=who, agent_id=agent_id)
async def skip_case(case_id, plan_id, actual_result="", executed_by="",
who=None, agent_id=None):
"""用例跳过pending → skipped有意不测须注明原因"""
return await _execute_transition(case_id, plan_id, [S_PENDING], S_SKIPPED, 'skip',
actual_result=actual_result, executed_by=executed_by,
who=who, agent_id=agent_id)
async def set_case_state(case_id, plan_id, from_state, to_state,
who=None, agent_id=None, detail=None):
"""通用 CAS 状态迁移兜底(跨产线自定义状态机用)。"""
return await _execute_transition(case_id, plan_id, [from_state], to_state, 'set_state',
actual_result=detail or '', who=who, agent_id=agent_id)
async def list_cases(plan_id, status=None, limit=100) -> list:
"""列出用例(可按状态过滤)。"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
conditions = ["plan_id=${pid}$"]
params = {"pid": plan_id}
if status:
conditions.append("status=${status}$")
params["status"] = status
where = " AND ".join(conditions)
try:
limit = int(limit)
except (TypeError, ValueError):
limit = 100
sql = (f"SELECT * FROM {TABLE} WHERE {where} "
f"ORDER BY created_at ASC LIMIT {limit}")
recs = await sor.sqlExe(sql, params)
# 释放 SELECT 元数据锁
await sor.sqlExe("COMMIT", {})
result = []
for rec in (recs or []):
result.append(_rec_to_dict(rec))
return result
def _rec_to_dict(rec):
"""把 sqlor 记录对象转成 dictsqlor 行是 DictObject必须 dict(rec) 取列)。"""
if isinstance(rec, dict):
return dict(rec)
try:
return dict(rec)
except (TypeError, ValueError):
if hasattr(rec, 'to_dict'):
try:
return rec.to_dict()
except Exception:
pass
return {}