"""测试用例能力 — sd_test_cases 的执行状态机语义化迁移(通用、产线无关)。 定位:用例 CRUD 走 xls2ui 生成的端点;本模块只做「执行结果状态流转」—— 每个迁移 CAS 原子(防并发/越权)+ 租户隔离 + 审计。 用例的执行状态机/流转规则在 test-case skill 里(LLM 读 skill 判断合法性);本模块只固化操作原语。 失败用例联动建 Bug(case_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 记录对象转成 dict(sqlor 行是 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 {}