Files
zeekerWatchmen/server/services/testcase_engine/generator.py
T
2026-05-25 15:09:42 +08:00

213 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.
# 测试用例生成器:基于 IR 生成测试用例集
import json
import logging
import uuid
from datetime import datetime, timezone
from server.config import settings
from server.core.llm_provider.router import router
from server.services.ir_engine.generator import ir_generator
logger = logging.getLogger("testflow")
DEFAULT_GEN_PROMPT = """你是一位资深的软件测试工程师。基于以下中间表示(IR),请生成完整的测试用例集。
测试设计原则:
1. 每个功能点至少覆盖一条正向用例和一条异常用例
2. 对于 P0 功能点,额外覆盖边界值测试
3. 用例步骤使用 Given-When-Then 结构
4. 预期结果必须明确可验证
输出格式:纯 JSON 数组,每个测试用例包含以下字段:
- id: 唯一标识符(字符串)
- module: 模块名
- feature: 功能点名称
- case_title: 用例标题
- preconditions: 前置条件
- steps: 测试步骤(Given-When-Then 格式)
- expected_result: 预期结果
- priority: 用例优先级 (P0/P1/P2)
- tags: 标签数组(如:["正向", "异常", "边界"]
## IR 内容:
{{ ir_content }}
请只输出 JSON 数组,不要用 ```json 代码块包裹。"""
class TestCaseGenerator:
"""Generate test case sets from IR YAML using LLM."""
def __init__(self):
self._tc_store: dict[str, dict] = {}
async def generate(self, ir_id: str, skill_name: str = "default") -> dict:
"""Generate test cases from an IR version."""
# Get IR content
ir_version = await ir_generator.get_ir(ir_id)
if not ir_version:
return {"error": f"IR version not found: {ir_id}", "cases": []}
# Handle both old YAML and new pipeline JSON formats
ir_content = ir_version.get("yaml_content", "")
ir_json = ir_version.get("ir_json")
# If we have the new pipeline format (rules array), convert to testcases directly
if ir_json and ir_json.get("rules"):
logger.info("[TC] 从 %d 条 IR 规则转换测试用例...", len(ir_json["rules"]))
cases = self._rules_to_cases(ir_json["rules"], ir_json.get("feature", ""))
tc_set_id = str(uuid.uuid4())[:8]
tc_set = self._store_tc(tc_set_id, ir_id, cases)
# Log distribution
p0 = sum(1 for c in cases if c.get("priority") == "P0")
p1 = sum(1 for c in cases if c.get("priority") == "P1")
logger.info("[TC] ✅ 生成 %d 条用例 (P0:%d P1:%d P2:%d)",
len(cases), p0, p1, len(cases) - p0 - p1)
return tc_set
# Fallback: use LLM with skill prompt
if not ir_content:
return {"error": "IR has no content", "cases": [], "tc_set_id": ""}
# Load skill prompt
skill = ir_generator.load_skill(skill_name)
prompt_template = skill.get("gen_cases_prompt", DEFAULT_GEN_PROMPT)
prompt = prompt_template.replace("{{ ir_content }}", ir_content)
logger.info("[TC] 调用 LLM 生成测试用例 for IR %s with skill %s", ir_id, skill_name)
client = router.get_text_client()
try:
raw = client.chat(
model=router.text_model,
messages=[{"role": "user", "content": prompt}],
temperature=0.0,
)
except Exception as e:
logger.error("Test case generation failed: %s", e)
return {"error": str(e), "cases": [], "tc_set_id": ""}
# Parse JSON response
cases = self._parse_json(raw)
# Create test case set
tc_set_id = str(uuid.uuid4())[:8]
tc_set = {
"tc_set_id": tc_set_id,
"ir_id": ir_id,
"cases": cases,
"case_count": len(cases),
"created_at": datetime.now(timezone.utc).isoformat(),
}
self._tc_store[tc_set_id] = tc_set
# Save to output
output_dir = settings.OUTPUT_DIR / ir_version.get("prd_id", "unknown") / "tc_sets"
output_dir.mkdir(parents=True, exist_ok=True)
tc_path = output_dir / f"tc_{tc_set_id}.json"
with open(tc_path, "w", encoding="utf-8") as f:
json.dump(tc_set, f, ensure_ascii=False, indent=2)
usg = client.usage
logger.info("Test cases generated: id=%s, count=%d, tokens: prompt=%d completion=%d total=%d",
tc_set_id, len(cases), usg["prompt_tokens"], usg["completion_tokens"], usg["total_tokens"])
return tc_set
async def get_tc_set(self, tc_set_id: str) -> dict | None:
"""Get a test case set by ID."""
return self._tc_store.get(tc_set_id)
@staticmethod
def _parse_json(raw: str) -> list[dict]:
"""Parse JSON from LLM response, handling code fences."""
text = raw.strip()
if text.startswith("```json") or text.startswith("```"):
first_nl = text.find("\n")
if first_nl != -1:
text = text[first_nl + 1:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
try:
parsed = json.loads(text)
if isinstance(parsed, list):
return parsed
if isinstance(parsed, dict) and "cases" in parsed:
return parsed["cases"]
return [parsed] if isinstance(parsed, dict) else []
except json.JSONDecodeError:
logger.warning("Failed to parse test case JSON, returning empty")
return []
def _rules_to_cases(self, rules: list[dict], feature: str) -> list[dict]:
"""Convert pipeline IR rules to test case format. TC IDs: TC-{feature_id}-{seq}"""
import re
feature_id = re.sub(r'[^A-Z]', '', feature.upper())[:6] or "FEAT"
if len(feature_id) < 2:
feature_id = (feature_id + "FEAT")[:4]
cases = []
for i, rule in enumerate(rules):
tc_id = f"TC-{feature_id}-{i+1:03d}"
desc = rule.get("description", "")
# Build steps string from trigger + actions
trigger = rule.get("trigger", {})
conditions = trigger.get("conditions", [])
cond_texts = []
for c in conditions:
unit = f" {c.get('unit', '')}" if c.get('unit') else ""
cond_texts.append(f"{c.get('signal','?')} {c.get('operator','?')} {c.get('value','?')}{unit}")
given = "Given " + (", ".join(cond_texts) if cond_texts else "触发条件满足")
actions = rule.get("actions", [])
action_texts = []
for a in actions:
if a.get("type") == "user_interaction":
action_texts.append(f"{a.get('description','')}('{a.get('content','')}')")
else:
action_texts.append(a.get("description", ""))
then = "Then " + ("; ".join(action_texts) if action_texts else "执行动作")
steps = f"{given}\nWhen 条件触发\n{then}"
# Determine tags
tags = ["正向"]
priority = rule.get("priority", "P2")
if priority == "P0":
tags.append("冒烟")
precond = rule.get("precondition", {})
if precond:
tags.append(f"{precond.get('app_type', '')} {precond.get('app_state', '')}".strip())
cases.append({
"id": tc_id,
"ir_rule_id": rule.get("rule_id", ""),
"module": feature,
"feature": rule.get("description", desc)[:40],
"case_title": desc[:60],
"preconditions": json.dumps(precond, ensure_ascii=False) if precond else "",
"steps": steps,
"expected_result": action_texts[-1] if action_texts else desc,
"priority": priority,
"tags": [t for t in tags if t],
})
return cases
def _store_tc(self, tc_set_id: str, ir_id: str, cases: list[dict]) -> dict:
"""Store a test case set and return its metadata."""
tc_set = {
"tc_set_id": tc_set_id,
"ir_id": ir_id,
"cases": cases,
"case_count": len(cases),
"created_at": datetime.now(timezone.utc).isoformat(),
}
self._tc_store[tc_set_id] = tc_set
return tc_set
tc_generator = TestCaseGenerator()