868 lines
32 KiB
Python
868 lines
32 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from collections import defaultdict
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from app.services.testing_pipeline.base import TestingTool, ToolExecutionResult
|
||
from app.services.testing_pipeline.rules import (
|
||
DECOMPOSE_FORCE_RULES,
|
||
EXPECTED_RESULT_PLACEHOLDER_MAP,
|
||
GENERIC_DECOMPOSITION_RULES,
|
||
REQUIREMENT_RULES,
|
||
REQUIREMENT_TYPES,
|
||
TYPE_SIGNAL_RULES,
|
||
)
|
||
|
||
|
||
def _clean_text(value: str) -> str:
|
||
return " ".join((value or "").replace("\n", " ").split())
|
||
|
||
|
||
def _truncate_text(value: str, max_len: int = 2000) -> str:
|
||
text = _clean_text(value)
|
||
if len(text) <= max_len:
|
||
return text
|
||
return f"{text[:max_len]}..."
|
||
|
||
|
||
def _safe_int(value: Any, default: int, low: int, high: int) -> int:
|
||
try:
|
||
parsed = int(value)
|
||
except Exception:
|
||
parsed = default
|
||
return max(low, min(parsed, high))
|
||
|
||
|
||
def _strip_instruction_prefix(value: str) -> str:
|
||
text = _clean_text(value)
|
||
if not text:
|
||
return text
|
||
|
||
lowered = text.lower()
|
||
if lowered.startswith("/testing"):
|
||
text = _clean_text(text[len("/testing") :])
|
||
|
||
prefixes = [
|
||
"为以下需求生成测试用例",
|
||
"根据以下需求生成测试用例",
|
||
"请根据以下需求生成测试用例",
|
||
"请根据需求生成测试用例",
|
||
"请生成测试用例",
|
||
"生成测试用例",
|
||
]
|
||
for prefix in prefixes:
|
||
if text.startswith(prefix):
|
||
for sep in (":", ":"):
|
||
idx = text.find(sep)
|
||
if idx != -1:
|
||
text = _clean_text(text[idx + 1 :])
|
||
break
|
||
else:
|
||
text = _clean_text(text[len(prefix) :])
|
||
break
|
||
|
||
pattern = re.compile(r"^(请)?(根据|按|基于).{0,40}(需求|场景).{0,30}(生成|输出).{0,20}(测试项|测试用例)[::]")
|
||
matched = pattern.match(text)
|
||
if matched:
|
||
text = _clean_text(text[matched.end() :])
|
||
|
||
return text
|
||
|
||
|
||
def _extract_focus_points(value: str, max_points: int = 6) -> List[str]:
|
||
text = _strip_instruction_prefix(value)
|
||
if not text:
|
||
return []
|
||
|
||
parts = [_clean_text(part) for part in re.split(r"[,,。;;]", text)]
|
||
parts = [part for part in parts if part]
|
||
|
||
ignored_tokens = ["生成测试用例", "测试项分解", "测试用例生成", "以下需求"]
|
||
filtered = [
|
||
part
|
||
for part in parts
|
||
if len(part) >= 4 and not any(token in part for token in ignored_tokens)
|
||
]
|
||
if not filtered:
|
||
filtered = parts
|
||
|
||
priority_keywords = [
|
||
"启停",
|
||
"开启",
|
||
"关闭",
|
||
"远程控制",
|
||
"保护",
|
||
"联动",
|
||
"状态",
|
||
"故障",
|
||
"恢复",
|
||
"切换",
|
||
"告警",
|
||
"模式",
|
||
"边界",
|
||
"时序",
|
||
]
|
||
priority = [part for part in filtered if any(keyword in part for keyword in priority_keywords)]
|
||
candidates = priority if priority else filtered
|
||
|
||
unique: List[str] = []
|
||
for part in candidates:
|
||
if part not in unique:
|
||
unique.append(part)
|
||
|
||
return unique[:max_points]
|
||
|
||
|
||
def _build_type_scores(text: str) -> Dict[str, int]:
|
||
scores: Dict[str, int] = {}
|
||
lowered = text.lower()
|
||
|
||
for req_type, rule in REQUIREMENT_RULES.items():
|
||
score = 0
|
||
if req_type in text:
|
||
score += 5
|
||
for keyword in rule.get("keywords", []):
|
||
if keyword.lower() in lowered:
|
||
score += 2
|
||
scores[req_type] = score
|
||
|
||
return scores
|
||
|
||
|
||
def _top_candidates(scores: Dict[str, int], top_n: int = 3) -> List[str]:
|
||
sorted_pairs = sorted(scores.items(), key=lambda pair: pair[1], reverse=True)
|
||
non_zero = [name for name, score in sorted_pairs if score > 0]
|
||
if non_zero:
|
||
return non_zero[:top_n]
|
||
return ["功能测试", "边界测试", "性能测试"][:top_n]
|
||
|
||
|
||
def _message_to_text(value: Any) -> str:
|
||
content = getattr(value, "content", value)
|
||
if isinstance(content, str):
|
||
return content
|
||
if isinstance(content, list):
|
||
chunks: List[str] = []
|
||
for item in content:
|
||
if isinstance(item, str):
|
||
chunks.append(item)
|
||
elif isinstance(item, dict):
|
||
text = item.get("text")
|
||
if isinstance(text, str):
|
||
chunks.append(text)
|
||
else:
|
||
chunks.append(str(item))
|
||
return "".join(chunks)
|
||
return str(content)
|
||
|
||
|
||
def _extract_json_object(value: str) -> Optional[Dict[str, Any]]:
|
||
text = (value or "").strip()
|
||
if not text:
|
||
return None
|
||
|
||
if text.startswith("```"):
|
||
text = re.sub(r"^```(?:json)?", "", text, flags=re.IGNORECASE).strip()
|
||
if text.endswith("```"):
|
||
text = text[:-3].strip()
|
||
|
||
try:
|
||
data = json.loads(text)
|
||
if isinstance(data, dict):
|
||
return data
|
||
except Exception:
|
||
pass
|
||
|
||
start = text.find("{")
|
||
if start == -1:
|
||
return None
|
||
|
||
depth = 0
|
||
for idx in range(start, len(text)):
|
||
ch = text[idx]
|
||
if ch == "{":
|
||
depth += 1
|
||
elif ch == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
fragment = text[start : idx + 1]
|
||
try:
|
||
data = json.loads(fragment)
|
||
if isinstance(data, dict):
|
||
return data
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
|
||
def _invoke_llm_json(context: Dict[str, Any], prompt: str) -> Optional[Dict[str, Any]]:
|
||
model = context.get("llm_model")
|
||
if model is None or not context.get("use_model_generation"):
|
||
return None
|
||
|
||
budget = context.get("llm_call_budget")
|
||
if isinstance(budget, int):
|
||
if budget <= 0:
|
||
return None
|
||
context["llm_call_budget"] = budget - 1
|
||
|
||
try:
|
||
response = model.invoke(prompt)
|
||
text = _message_to_text(response)
|
||
return _extract_json_object(text)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _invoke_llm_text(context: Dict[str, Any], prompt: str) -> str:
|
||
model = context.get("llm_model")
|
||
if model is None or not context.get("use_model_generation"):
|
||
return ""
|
||
|
||
budget = context.get("llm_call_budget")
|
||
if isinstance(budget, int):
|
||
if budget <= 0:
|
||
return ""
|
||
context["llm_call_budget"] = budget - 1
|
||
|
||
try:
|
||
response = model.invoke(prompt)
|
||
return _clean_text(_message_to_text(response))
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def _normalize_item_entry(item: Any) -> Optional[Dict[str, Any]]:
|
||
if isinstance(item, str):
|
||
content = _clean_text(item)
|
||
if not content:
|
||
return None
|
||
return {"content": content, "coverage_tags": []}
|
||
|
||
if isinstance(item, dict):
|
||
content = _clean_text(str(item.get("content", "")))
|
||
if not content:
|
||
return None
|
||
tags = item.get("coverage_tags") or item.get("covered_points") or []
|
||
if not isinstance(tags, list):
|
||
tags = [str(tags)]
|
||
tags = [_clean_text(str(tag)) for tag in tags if _clean_text(str(tag))]
|
||
return {"content": content, "coverage_tags": tags}
|
||
|
||
return None
|
||
|
||
|
||
def _dedupe_items(items: List[Dict[str, Any]], max_items: int) -> List[Dict[str, Any]]:
|
||
merged: Dict[str, Dict[str, Any]] = {}
|
||
for item in items:
|
||
content = _clean_text(item.get("content", ""))
|
||
if not content:
|
||
continue
|
||
existing = merged.get(content)
|
||
if existing is None:
|
||
merged[content] = {
|
||
"content": content,
|
||
"coverage_tags": list(item.get("coverage_tags") or []),
|
||
}
|
||
else:
|
||
existing_tags = set(existing.get("coverage_tags") or [])
|
||
for tag in item.get("coverage_tags") or []:
|
||
if tag and tag not in existing_tags:
|
||
existing_tags.add(tag)
|
||
existing["coverage_tags"] = list(existing_tags)
|
||
|
||
deduped = list(merged.values())
|
||
return deduped[:max_items]
|
||
|
||
|
||
def _pick_expected_result_placeholder(content: str, abnormal: bool) -> str:
|
||
text = content or ""
|
||
|
||
if abnormal or any(token in text for token in ["非法", "异常", "错误", "拒绝", "越界", "失败"]):
|
||
return "{{error_message}}"
|
||
if any(token in text for token in ["状态", "切换", "转换", "恢复"]):
|
||
return "{{state_change}}"
|
||
if any(token in text for token in ["数据库", "存储", "落库", "持久化"]):
|
||
return "{{data_persistence}}"
|
||
if any(token in text for token in ["界面", "UI", "页面", "按钮", "提示"]):
|
||
return "{{ui_display}}"
|
||
return "{{return_value}}"
|
||
|
||
|
||
class IdentifyRequirementTypeTool(TestingTool):
|
||
name = "identify-requirement-type"
|
||
|
||
def execute(self, context: Dict[str, Any]) -> ToolExecutionResult:
|
||
raw_text = _clean_text(context.get("user_requirement_text", ""))
|
||
text = _strip_instruction_prefix(raw_text)
|
||
if not text:
|
||
text = raw_text
|
||
|
||
max_focus_points = _safe_int(context.get("max_focus_points"), 6, 3, 12)
|
||
provided_type = _clean_text(context.get("requirement_type_input", ""))
|
||
focus_points = _extract_focus_points(text, max_points=max_focus_points)
|
||
fallback_used = False
|
||
|
||
if provided_type in REQUIREMENT_TYPES:
|
||
result = {
|
||
"requirement_type": provided_type,
|
||
"reason": "用户已显式指定需求类型,系统按指定类型执行。",
|
||
"candidates": [],
|
||
"scores": {},
|
||
"secondary_types": [],
|
||
}
|
||
else:
|
||
scores = _build_type_scores(text)
|
||
sorted_pairs = sorted(scores.items(), key=lambda pair: pair[1], reverse=True)
|
||
best_type, best_score = sorted_pairs[0]
|
||
secondary = [name for name, score in sorted_pairs[1:4] if score > 0]
|
||
|
||
if best_score <= 0:
|
||
fallback_used = True
|
||
candidates = _top_candidates(scores)
|
||
result = {
|
||
"requirement_type": "未知类型",
|
||
"reason": "未命中明确分类规则,已回退到未知类型并提供最接近候选。",
|
||
"candidates": candidates,
|
||
"scores": scores,
|
||
"secondary_types": [],
|
||
}
|
||
else:
|
||
signal = TYPE_SIGNAL_RULES.get(best_type, "")
|
||
result = {
|
||
"requirement_type": best_type,
|
||
"reason": f"命中{best_type}识别信号。{signal}",
|
||
"candidates": [],
|
||
"scores": scores,
|
||
"secondary_types": secondary,
|
||
}
|
||
|
||
context["requirement_type_result"] = result
|
||
context["normalized_requirement_text"] = text
|
||
context["requirement_focus_points"] = focus_points
|
||
context["knowledge_used"] = bool(context.get("knowledge_context"))
|
||
|
||
return ToolExecutionResult(
|
||
context=context,
|
||
output_summary=(
|
||
f"type={result['requirement_type']}; candidates={len(result['candidates'])}; "
|
||
f"secondary_types={len(result.get('secondary_types', []))}; focus_points={len(focus_points)}"
|
||
),
|
||
fallback_used=fallback_used,
|
||
)
|
||
|
||
|
||
class DecomposeTestItemsTool(TestingTool):
|
||
name = "decompose-test-items"
|
||
|
||
@staticmethod
|
||
def _seed_items(
|
||
req_type: str,
|
||
req_text: str,
|
||
focus_points: List[str],
|
||
max_items: int,
|
||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||
if req_type in REQUIREMENT_RULES:
|
||
source_rules = REQUIREMENT_RULES[req_type]
|
||
normal_templates = list(source_rules.get("normal", []))
|
||
abnormal_templates = list(source_rules.get("abnormal", []))
|
||
else:
|
||
normal_templates = list(GENERIC_DECOMPOSITION_RULES["normal"])
|
||
abnormal_templates = list(GENERIC_DECOMPOSITION_RULES["abnormal"])
|
||
|
||
normal: List[Dict[str, Any]] = []
|
||
abnormal: List[Dict[str, Any]] = []
|
||
|
||
for template in normal_templates:
|
||
normal.append({"content": template, "coverage_tags": [req_type]})
|
||
for template in abnormal_templates:
|
||
abnormal.append({"content": template, "coverage_tags": [req_type]})
|
||
|
||
for point in focus_points:
|
||
normal.extend(
|
||
[
|
||
{
|
||
"content": f"验证{point}在标准作业流程下稳定执行且结果符合业务约束。",
|
||
"coverage_tags": [point, "正常流程"],
|
||
},
|
||
{
|
||
"content": f"验证{point}与相关联动控制、状态同步和回执反馈的一致性。",
|
||
"coverage_tags": [point, "联动一致性"],
|
||
},
|
||
]
|
||
)
|
||
abnormal.extend(
|
||
[
|
||
{
|
||
"content": f"验证{point}在非法输入、错误指令或权限异常时的保护与拒绝机制。",
|
||
"coverage_tags": [point, "异常输入"],
|
||
},
|
||
{
|
||
"content": f"验证{point}在边界条件、时序冲突或设备故障下的告警和恢复行为。",
|
||
"coverage_tags": [point, "边界异常"],
|
||
},
|
||
]
|
||
)
|
||
|
||
if any(token in req_text for token in ["手册", "操作手册", "用户手册", "作业指导"]):
|
||
normal.append(
|
||
{
|
||
"content": "验证需求说明未显式给出但在用户手册或操作手册体现的功能流程。",
|
||
"coverage_tags": ["手册功能"],
|
||
}
|
||
)
|
||
|
||
return _dedupe_items(normal, max_items), _dedupe_items(abnormal, max_items)
|
||
|
||
@staticmethod
|
||
def _generate_by_llm(context: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||
req_result = context.get("requirement_type_result", {})
|
||
req_type = req_result.get("requirement_type", "未知类型")
|
||
req_text = context.get("normalized_requirement_text", "")
|
||
focus_points = context.get("requirement_focus_points", [])
|
||
max_items = _safe_int(context.get("max_items_per_group"), 12, 4, 30)
|
||
knowledge_context = _truncate_text(context.get("knowledge_context", ""), max_len=2500)
|
||
|
||
prompt = f"""
|
||
你是资深测试分析师。请根据需求、分解规则和知识库片段,生成尽可能覆盖要点的测试项。
|
||
|
||
需求文本:{req_text}
|
||
需求类型:{req_type}
|
||
需求要点:{focus_points}
|
||
知识库片段:{knowledge_context or '无'}
|
||
|
||
分解约束:
|
||
1. 正常测试与异常测试必须分组输出。
|
||
2. 每条测试项必须可执行、可验证,避免模板化空话。
|
||
3. 尽可能覆盖全部需求要点;每组建议输出6-{max_items}条。
|
||
4. 优先生成与需求对象/控制逻辑/异常处理/边界条件强相关的测试项。
|
||
|
||
请仅输出 JSON 对象,结构如下:
|
||
{{
|
||
"normal_test_items": [
|
||
{{"content": "...", "coverage_tags": ["..."]}}
|
||
],
|
||
"abnormal_test_items": [
|
||
{{"content": "...", "coverage_tags": ["..."]}}
|
||
]
|
||
}}
|
||
""".strip()
|
||
|
||
data = _invoke_llm_json(context, prompt)
|
||
if not data:
|
||
return [], []
|
||
|
||
normal_raw = data.get("normal_test_items", [])
|
||
abnormal_raw = data.get("abnormal_test_items", [])
|
||
|
||
normal: List[Dict[str, Any]] = []
|
||
abnormal: List[Dict[str, Any]] = []
|
||
|
||
for item in normal_raw if isinstance(normal_raw, list) else []:
|
||
normalized = _normalize_item_entry(item)
|
||
if normalized:
|
||
normal.append(normalized)
|
||
|
||
for item in abnormal_raw if isinstance(abnormal_raw, list) else []:
|
||
normalized = _normalize_item_entry(item)
|
||
if normalized:
|
||
abnormal.append(normalized)
|
||
|
||
return _dedupe_items(normal, max_items), _dedupe_items(abnormal, max_items)
|
||
|
||
def execute(self, context: Dict[str, Any]) -> ToolExecutionResult:
|
||
req_result = context.get("requirement_type_result", {})
|
||
req_type = req_result.get("requirement_type", "未知类型")
|
||
req_text = context.get("normalized_requirement_text") or _strip_instruction_prefix(
|
||
context.get("user_requirement_text", "")
|
||
)
|
||
focus_points = context.get("requirement_focus_points", [])
|
||
max_items = _safe_int(context.get("max_items_per_group"), 12, 4, 30)
|
||
|
||
seeded_normal, seeded_abnormal = self._seed_items(req_type, req_text, focus_points, max_items)
|
||
llm_normal, llm_abnormal = self._generate_by_llm(context)
|
||
|
||
merged_normal = _dedupe_items(llm_normal + seeded_normal, max_items)
|
||
merged_abnormal = _dedupe_items(llm_abnormal + seeded_abnormal, max_items)
|
||
|
||
fallback_used = not bool(llm_normal or llm_abnormal)
|
||
|
||
normal_items: List[Dict[str, Any]] = []
|
||
abnormal_items: List[Dict[str, Any]] = []
|
||
|
||
for idx, item in enumerate(merged_normal, start=1):
|
||
normal_items.append(
|
||
{
|
||
"id": f"N{idx}",
|
||
"content": item["content"],
|
||
"coverage_tags": item.get("coverage_tags", []),
|
||
}
|
||
)
|
||
|
||
for idx, item in enumerate(merged_abnormal, start=1):
|
||
abnormal_items.append(
|
||
{
|
||
"id": f"E{idx}",
|
||
"content": item["content"],
|
||
"coverage_tags": item.get("coverage_tags", []),
|
||
}
|
||
)
|
||
|
||
context["test_items"] = {
|
||
"normal": normal_items,
|
||
"abnormal": abnormal_items,
|
||
}
|
||
context["decompose_force_rules"] = DECOMPOSE_FORCE_RULES
|
||
|
||
return ToolExecutionResult(
|
||
context=context,
|
||
output_summary=(
|
||
f"normal_items={len(normal_items)}; abnormal_items={len(abnormal_items)}; "
|
||
f"llm_items={len(llm_normal) + len(llm_abnormal)}"
|
||
),
|
||
fallback_used=fallback_used,
|
||
)
|
||
|
||
|
||
class GenerateTestCasesTool(TestingTool):
|
||
name = "generate-test-cases"
|
||
|
||
@staticmethod
|
||
def _build_fallback_steps(item_content: str, abnormal: bool, variant: str) -> List[str]:
|
||
if abnormal:
|
||
return [
|
||
"确认测试前置环境、设备状态与日志采集开关已准备就绪。",
|
||
f"准备异常场景“{variant}”所需的输入数据、操作账号和触发条件。",
|
||
f"在目标对象执行异常触发操作,重点验证:{item_content}",
|
||
"持续观察系统返回码、错误文案、告警信息与日志链路完整性。",
|
||
"检查保护机制是否生效,包括拒绝策略、回滚行为和状态一致性。",
|
||
"记录证据并复位环境,确认异常处理后系统可恢复到稳定状态。",
|
||
]
|
||
|
||
return [
|
||
"确认测试环境、设备连接状态和前置业务数据均已初始化。",
|
||
f"准备“{variant}”所需输入参数、操作路径和判定阈值。",
|
||
f"在目标对象执行业务控制流程,重点验证:{item_content}",
|
||
"校验关键返回值、状态变化、控制回执及界面或接口反馈结果。",
|
||
"检查联动模块、日志记录和数据落库是否满足一致性要求。",
|
||
"沉淀测试证据并恢复环境,确保后续用例可重复执行。",
|
||
]
|
||
|
||
def _generate_cases_by_llm(
|
||
self,
|
||
context: Dict[str, Any],
|
||
item: Dict[str, Any],
|
||
abnormal: bool,
|
||
cases_per_item: int,
|
||
) -> List[Dict[str, Any]]:
|
||
req_text = context.get("normalized_requirement_text", "")
|
||
knowledge_context = _truncate_text(context.get("knowledge_context", ""), max_len=1800)
|
||
|
||
prompt = f"""
|
||
你是资深测试工程师。请围绕给定测试项生成详细测试用例。
|
||
|
||
需求:{req_text}
|
||
测试项:{item.get('content', '')}
|
||
测试类型:{'异常测试' if abnormal else '正常测试'}
|
||
知识库片段:{knowledge_context or '无'}
|
||
|
||
要求:
|
||
1. 生成 {cases_per_item}-{max(cases_per_item + 1, cases_per_item)} 条测试用例。
|
||
2. 每条用例包含 test_content 与 operation_steps。
|
||
3. operation_steps 必须详细,至少5步,包含前置、执行、观察、校验与证据留存。
|
||
4. 内容必须围绕当前测试项,不要输出空洞模板。
|
||
|
||
仅输出 JSON:
|
||
{{
|
||
"test_cases": [
|
||
{{
|
||
"title": "...",
|
||
"test_content": "...",
|
||
"operation_steps": ["...", "..."]
|
||
}}
|
||
]
|
||
}}
|
||
""".strip()
|
||
|
||
data = _invoke_llm_json(context, prompt)
|
||
if not data:
|
||
return []
|
||
|
||
raw_cases = data.get("test_cases", [])
|
||
if not isinstance(raw_cases, list):
|
||
return []
|
||
|
||
normalized_cases: List[Dict[str, Any]] = []
|
||
for case in raw_cases:
|
||
if not isinstance(case, dict):
|
||
continue
|
||
test_content = _clean_text(str(case.get("test_content", "")))
|
||
if not test_content:
|
||
continue
|
||
steps = case.get("operation_steps", [])
|
||
if not isinstance(steps, list):
|
||
continue
|
||
cleaned_steps = [_clean_text(str(step)) for step in steps if _clean_text(str(step))]
|
||
if len(cleaned_steps) < 5:
|
||
continue
|
||
normalized_cases.append(
|
||
{
|
||
"title": _clean_text(str(case.get("title", ""))),
|
||
"test_content": test_content,
|
||
"operation_steps": cleaned_steps,
|
||
}
|
||
)
|
||
|
||
return normalized_cases[: max(1, cases_per_item)]
|
||
|
||
def execute(self, context: Dict[str, Any]) -> ToolExecutionResult:
|
||
test_items = context.get("test_items", {})
|
||
cases_per_item = _safe_int(context.get("cases_per_item"), 2, 1, 5)
|
||
|
||
normal_cases: List[Dict[str, Any]] = []
|
||
abnormal_cases: List[Dict[str, Any]] = []
|
||
llm_case_count = 0
|
||
|
||
for item in test_items.get("normal", []):
|
||
generated = self._generate_cases_by_llm(context, item, abnormal=False, cases_per_item=cases_per_item)
|
||
if not generated:
|
||
generated = [
|
||
{
|
||
"title": "标准流程验证",
|
||
"test_content": f"验证{item['content']}",
|
||
"operation_steps": self._build_fallback_steps(item["content"], False, "标准流程"),
|
||
},
|
||
{
|
||
"title": "边界与联动验证",
|
||
"test_content": f"验证{item['content']}在边界条件和联动场景下的稳定性",
|
||
"operation_steps": self._build_fallback_steps(item["content"], False, "边界与联动"),
|
||
},
|
||
][:cases_per_item]
|
||
else:
|
||
llm_case_count += len(generated)
|
||
|
||
for idx, case in enumerate(generated, start=1):
|
||
merged_content = _clean_text(case.get("test_content", item["content"]))
|
||
placeholder = _pick_expected_result_placeholder(merged_content, abnormal=False)
|
||
normal_cases.append(
|
||
{
|
||
"id": f"{item['id']}-C{idx}",
|
||
"item_id": item["id"],
|
||
"title": _clean_text(case.get("title", "")),
|
||
"operation_steps": case.get("operation_steps", []),
|
||
"test_content": merged_content,
|
||
"expected_result_placeholder": placeholder,
|
||
}
|
||
)
|
||
|
||
for item in test_items.get("abnormal", []):
|
||
generated = self._generate_cases_by_llm(context, item, abnormal=True, cases_per_item=cases_per_item)
|
||
if not generated:
|
||
generated = [
|
||
{
|
||
"title": "非法输入与权限异常验证",
|
||
"test_content": f"验证{item['content']}在非法输入与权限异常下的处理表现",
|
||
"operation_steps": self._build_fallback_steps(item["content"], True, "非法输入与权限异常"),
|
||
},
|
||
{
|
||
"title": "故障与时序冲突验证",
|
||
"test_content": f"验证{item['content']}在故障和时序冲突场景下的保护行为",
|
||
"operation_steps": self._build_fallback_steps(item["content"], True, "故障与时序冲突"),
|
||
},
|
||
][:cases_per_item]
|
||
else:
|
||
llm_case_count += len(generated)
|
||
|
||
for idx, case in enumerate(generated, start=1):
|
||
merged_content = _clean_text(case.get("test_content", item["content"]))
|
||
placeholder = _pick_expected_result_placeholder(merged_content, abnormal=True)
|
||
abnormal_cases.append(
|
||
{
|
||
"id": f"{item['id']}-C{idx}",
|
||
"item_id": item["id"],
|
||
"title": _clean_text(case.get("title", "")),
|
||
"operation_steps": case.get("operation_steps", []),
|
||
"test_content": merged_content,
|
||
"expected_result_placeholder": placeholder,
|
||
}
|
||
)
|
||
|
||
context["test_cases"] = {
|
||
"normal": normal_cases,
|
||
"abnormal": abnormal_cases,
|
||
}
|
||
|
||
return ToolExecutionResult(
|
||
context=context,
|
||
output_summary=(
|
||
f"normal_cases={len(normal_cases)}; abnormal_cases={len(abnormal_cases)}; llm_cases={llm_case_count}"
|
||
),
|
||
fallback_used=llm_case_count == 0,
|
||
)
|
||
|
||
|
||
class BuildExpectedResultsTool(TestingTool):
|
||
name = "build_expected_results"
|
||
|
||
def _expected_for_case(self, context: Dict[str, Any], case: Dict[str, Any], abnormal: bool) -> str:
|
||
placeholder = case.get("expected_result_placeholder", "{{return_value}}")
|
||
if placeholder not in EXPECTED_RESULT_PLACEHOLDER_MAP:
|
||
placeholder = "{{return_value}}"
|
||
|
||
req_text = context.get("normalized_requirement_text", "")
|
||
knowledge_context = _truncate_text(context.get("knowledge_context", ""), max_len=1200)
|
||
prompt = f"""
|
||
请基于以下信息生成一条可验证、可度量的测试预期结果,避免模板化空话。
|
||
|
||
需求:{req_text}
|
||
测试内容:{case.get('test_content', '')}
|
||
测试类型:{'异常测试' if abnormal else '正常测试'}
|
||
占位符语义:{placeholder} -> {EXPECTED_RESULT_PLACEHOLDER_MAP.get(placeholder, '')}
|
||
知识库片段:{knowledge_context or '无'}
|
||
|
||
输出要求:
|
||
1. 仅输出一句中文预期结果。
|
||
2. 结果必须可判定成功/失败。
|
||
3. 包含关键观测项(返回值、状态、告警、日志、数据一致性中的相关项)。
|
||
""".strip()
|
||
|
||
llm_text = _invoke_llm_text(context, prompt)
|
||
if llm_text:
|
||
return _truncate_text(llm_text, max_len=220)
|
||
|
||
test_content = _clean_text(case.get("test_content", ""))
|
||
if placeholder == "{{error_message}}":
|
||
return f"触发{test_content}后,系统应返回明确错误码与错误文案,拒绝非法请求且核心状态保持一致。"
|
||
if placeholder == "{{state_change}}":
|
||
return f"执行{test_content}后,系统状态转换应符合需求定义,状态变化可被日志与回执共同验证。"
|
||
if placeholder == "{{data_persistence}}":
|
||
return f"执行{test_content}后,数据库或存储层应产生符合约束的持久化结果且无脏数据。"
|
||
if placeholder == "{{ui_display}}":
|
||
return f"执行{test_content}后,界面应展示与控制结果一致的反馈信息且提示可被用户执行。"
|
||
|
||
if abnormal:
|
||
return f"执行异常场景“{test_content}”后,系统应触发保护策略并输出可追溯日志,业务状态保持可恢复。"
|
||
|
||
return f"执行“{test_content}”后,返回值与状态变化应满足需求约束,关键结果可通过日志或回执验证。"
|
||
|
||
def execute(self, context: Dict[str, Any]) -> ToolExecutionResult:
|
||
test_cases = context.get("test_cases", {})
|
||
|
||
normal_expected: List[Dict[str, str]] = []
|
||
abnormal_expected: List[Dict[str, str]] = []
|
||
|
||
for case in test_cases.get("normal", []):
|
||
normal_expected.append(
|
||
{
|
||
"id": case["id"],
|
||
"case_id": case["id"],
|
||
"result": self._expected_for_case(context, case, abnormal=False),
|
||
}
|
||
)
|
||
|
||
for case in test_cases.get("abnormal", []):
|
||
abnormal_expected.append(
|
||
{
|
||
"id": case["id"],
|
||
"case_id": case["id"],
|
||
"result": self._expected_for_case(context, case, abnormal=True),
|
||
}
|
||
)
|
||
|
||
context["expected_results"] = {
|
||
"normal": normal_expected,
|
||
"abnormal": abnormal_expected,
|
||
}
|
||
|
||
return ToolExecutionResult(
|
||
context=context,
|
||
output_summary=(
|
||
f"normal_expected={len(normal_expected)}; abnormal_expected={len(abnormal_expected)}"
|
||
),
|
||
)
|
||
|
||
|
||
class FormatOutputTool(TestingTool):
|
||
name = "format_output"
|
||
|
||
@staticmethod
|
||
def _format_case_block(case: Dict[str, Any], index: int) -> List[str]:
|
||
item_id = case.get("item_id", case.get("id", ""))
|
||
title = _clean_text(case.get("title", ""))
|
||
|
||
block: List[str] = []
|
||
block.append(f"{index}. [用例 {case['id']}](对应测试项 {item_id}):{case.get('test_content', '')}")
|
||
if title:
|
||
block.append(f" 场景标题:{title}")
|
||
block.append(" 操作步骤:")
|
||
for step_idx, step in enumerate(case.get("operation_steps", []), start=1):
|
||
block.append(f" {step_idx}) {step}")
|
||
return block
|
||
|
||
def execute(self, context: Dict[str, Any]) -> ToolExecutionResult:
|
||
test_items = context.get("test_items", {"normal": [], "abnormal": []})
|
||
test_cases = context.get("test_cases", {"normal": [], "abnormal": []})
|
||
expected_results = context.get("expected_results", {"normal": [], "abnormal": []})
|
||
|
||
lines: List[str] = []
|
||
|
||
lines.append("**测试项**")
|
||
lines.append("")
|
||
lines.append("**正常测试**:")
|
||
for index, item in enumerate(test_items.get("normal", []), start=1):
|
||
lines.append(f"{index}. [测试项 {item['id']}]:{item['content']}")
|
||
lines.append("")
|
||
lines.append("**异常测试**:")
|
||
for index, item in enumerate(test_items.get("abnormal", []), start=1):
|
||
lines.append(f"{index}. [测试项 {item['id']}]:{item['content']}")
|
||
|
||
lines.append("")
|
||
lines.append("**测试用例**")
|
||
lines.append("")
|
||
lines.append("**正常测试**:")
|
||
for index, case in enumerate(test_cases.get("normal", []), start=1):
|
||
lines.extend(self._format_case_block(case, index))
|
||
lines.append("")
|
||
lines.append("**异常测试**:")
|
||
for index, case in enumerate(test_cases.get("abnormal", []), start=1):
|
||
lines.extend(self._format_case_block(case, index))
|
||
|
||
lines.append("")
|
||
lines.append("**预期成果**")
|
||
lines.append("")
|
||
lines.append("**正常测试**:")
|
||
for index, expected in enumerate(expected_results.get("normal", []), start=1):
|
||
lines.append(
|
||
f"{index}. [预期 {expected['id']}](对应用例 {expected['case_id']}):{expected['result']}"
|
||
)
|
||
lines.append("")
|
||
lines.append("**异常测试**:")
|
||
for index, expected in enumerate(expected_results.get("abnormal", []), start=1):
|
||
lines.append(
|
||
f"{index}. [预期 {expected['id']}](对应用例 {expected['case_id']}):{expected['result']}"
|
||
)
|
||
|
||
context["formatted_output"] = "\n".join(lines)
|
||
context["structured_output"] = {
|
||
"test_items": test_items,
|
||
"test_cases": test_cases,
|
||
"expected_results": expected_results,
|
||
}
|
||
|
||
return ToolExecutionResult(
|
||
context=context,
|
||
output_summary="formatted_sections=3",
|
||
)
|
||
|
||
|
||
def build_default_tool_chain() -> List[TestingTool]:
|
||
return [
|
||
IdentifyRequirementTypeTool(),
|
||
DecomposeTestItemsTool(),
|
||
GenerateTestCasesTool(),
|
||
BuildExpectedResultsTool(),
|
||
FormatOutputTool(),
|
||
]
|