init the project
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
# 测试用例生成器:基于 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()
|
||||
Reference in New Issue
Block a user