609 lines
24 KiB
Python
609 lines
24 KiB
Python
|
|
"""
|
|||
|
|
覆盖率分析与缺口识别
|
|||
|
|
══════════════════════════════════════════════════════════════
|
|||
|
|
修复:
|
|||
|
|
- 移除对已删除属性 request_parameters / url_parameters / response 的引用
|
|||
|
|
- HTTP 与 function 接口统一使用 iface.in_parameters / iface.out_parameters
|
|||
|
|
- HTTP 接口的返回字段覆盖统一从 return_info.on_success / on_failure 读取
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
import logging
|
|||
|
|
from dataclasses import dataclass, field
|
|||
|
|
from core.parser import InterfaceInfo
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ══════════════════════════════════════════════════════════════
|
|||
|
|
# 数据结构
|
|||
|
|
# ══════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class InterfaceCoverage:
|
|||
|
|
interface_name: str
|
|||
|
|
protocol: str
|
|||
|
|
requirement_id: str = ""
|
|||
|
|
is_covered: bool = False
|
|||
|
|
|
|||
|
|
# 入参覆盖(in / inout)
|
|||
|
|
all_in_params: set[str] = field(default_factory=set)
|
|||
|
|
covered_in_params: set[str] = field(default_factory=set)
|
|||
|
|
|
|||
|
|
# 出参断言覆盖(out / inout)
|
|||
|
|
all_out_params: set[str] = field(default_factory=set)
|
|||
|
|
asserted_out_params: set[str] = field(default_factory=set)
|
|||
|
|
|
|||
|
|
# on_success 返回字段断言覆盖
|
|||
|
|
all_success_fields: set[str] = field(default_factory=set)
|
|||
|
|
covered_success_fields: set[str] = field(default_factory=set)
|
|||
|
|
|
|||
|
|
# on_failure 返回字段断言覆盖
|
|||
|
|
all_failure_fields: set[str] = field(default_factory=set)
|
|||
|
|
covered_failure_fields: set[str] = field(default_factory=set)
|
|||
|
|
|
|||
|
|
# 异常断言(on_failure = raises Exception)
|
|||
|
|
expects_exception: bool = False
|
|||
|
|
exception_case_covered: bool = False
|
|||
|
|
|
|||
|
|
has_positive_case: bool = False
|
|||
|
|
has_negative_case: bool = False
|
|||
|
|
covering_test_ids: list[str] = field(default_factory=list)
|
|||
|
|
|
|||
|
|
# ── 覆盖率计算 ────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def in_param_coverage_rate(self) -> float:
|
|||
|
|
if not self.all_in_params:
|
|||
|
|
return 1.0
|
|||
|
|
return len(self.covered_in_params) / len(self.all_in_params)
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def out_param_coverage_rate(self) -> float:
|
|||
|
|
if not self.all_out_params:
|
|||
|
|
return 1.0
|
|||
|
|
return len(self.asserted_out_params) / len(self.all_out_params)
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def success_field_coverage_rate(self) -> float:
|
|||
|
|
if not self.all_success_fields:
|
|||
|
|
return 1.0
|
|||
|
|
return len(self.covered_success_fields) / len(self.all_success_fields)
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def failure_field_coverage_rate(self) -> float:
|
|||
|
|
if not self.all_failure_fields:
|
|||
|
|
return 1.0
|
|||
|
|
return len(self.covered_failure_fields) / len(self.all_failure_fields)
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def uncovered_in_params(self) -> set[str]:
|
|||
|
|
return self.all_in_params - self.covered_in_params
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def uncovered_out_params(self) -> set[str]:
|
|||
|
|
return self.all_out_params - self.asserted_out_params
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def uncovered_success_fields(self) -> set[str]:
|
|||
|
|
return self.all_success_fields - self.covered_success_fields
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def uncovered_failure_fields(self) -> set[str]:
|
|||
|
|
return self.all_failure_fields - self.covered_failure_fields
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class RequirementCoverage:
|
|||
|
|
requirement: str
|
|||
|
|
requirement_id: str = ""
|
|||
|
|
covering_test_ids: list[str] = field(default_factory=list)
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def is_covered(self) -> bool:
|
|||
|
|
return bool(self.covering_test_ids)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class Gap:
|
|||
|
|
gap_type: str # 见下方常量
|
|||
|
|
severity: str # critical | high | medium | low
|
|||
|
|
target: str
|
|||
|
|
detail: str
|
|||
|
|
suggestion: str
|
|||
|
|
|
|||
|
|
|
|||
|
|
# gap_type 常量
|
|||
|
|
GAP_INTERFACE_NOT_COVERED = "interface_not_covered"
|
|||
|
|
GAP_IN_PARAM_NOT_COVERED = "in_param_not_covered"
|
|||
|
|
GAP_OUT_PARAM_NOT_ASSERTED = "out_param_not_asserted"
|
|||
|
|
GAP_SUCCESS_FIELD_NOT_ASSERTED = "success_field_not_asserted"
|
|||
|
|
GAP_FAILURE_FIELD_NOT_ASSERTED = "failure_field_not_asserted"
|
|||
|
|
GAP_EXCEPTION_NOT_TESTED = "exception_case_not_tested"
|
|||
|
|
GAP_MISSING_POSITIVE = "missing_positive_case"
|
|||
|
|
GAP_MISSING_NEGATIVE = "missing_negative_case"
|
|||
|
|
GAP_REQUIREMENT_NOT_COVERED = "requirement_not_covered"
|
|||
|
|
GAP_TEST_FAILED = "test_failed"
|
|||
|
|
GAP_TEST_ERROR = "test_error"
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class CoverageReport:
|
|||
|
|
total_interfaces: int = 0
|
|||
|
|
covered_interfaces: int = 0
|
|||
|
|
total_requirements: int = 0
|
|||
|
|
covered_requirements: int = 0
|
|||
|
|
total_test_cases: int = 0
|
|||
|
|
passed_test_cases: int = 0
|
|||
|
|
failed_test_cases: int = 0
|
|||
|
|
error_test_cases: int = 0
|
|||
|
|
|
|||
|
|
interface_coverages: list[InterfaceCoverage] = field(default_factory=list)
|
|||
|
|
requirement_coverages: list[RequirementCoverage] = field(default_factory=list)
|
|||
|
|
gaps: list[Gap] = field(default_factory=list)
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def interface_coverage_rate(self) -> float:
|
|||
|
|
return self.covered_interfaces / self.total_interfaces \
|
|||
|
|
if self.total_interfaces else 0.0
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def requirement_coverage_rate(self) -> float:
|
|||
|
|
return self.covered_requirements / self.total_requirements \
|
|||
|
|
if self.total_requirements else 0.0
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def pass_rate(self) -> float:
|
|||
|
|
return self.passed_test_cases / self.total_test_cases \
|
|||
|
|
if self.total_test_cases else 0.0
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def avg_in_param_coverage_rate(self) -> float:
|
|||
|
|
rates = [
|
|||
|
|
ic.in_param_coverage_rate
|
|||
|
|
for ic in self.interface_coverages
|
|||
|
|
if ic.all_in_params
|
|||
|
|
]
|
|||
|
|
return sum(rates) / len(rates) if rates else 1.0
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def avg_success_field_coverage_rate(self) -> float:
|
|||
|
|
rates = [
|
|||
|
|
ic.success_field_coverage_rate
|
|||
|
|
for ic in self.interface_coverages
|
|||
|
|
if ic.all_success_fields
|
|||
|
|
]
|
|||
|
|
return sum(rates) / len(rates) if rates else 1.0
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def avg_failure_field_coverage_rate(self) -> float:
|
|||
|
|
rates = [
|
|||
|
|
ic.failure_field_coverage_rate
|
|||
|
|
for ic in self.interface_coverages
|
|||
|
|
if ic.all_failure_fields
|
|||
|
|
]
|
|||
|
|
return sum(rates) / len(rates) if rates else 1.0
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def critical_gap_count(self) -> int:
|
|||
|
|
return sum(1 for g in self.gaps if g.severity == "critical")
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def high_gap_count(self) -> int:
|
|||
|
|
return sum(1 for g in self.gaps if g.severity == "high")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ══════════════════════════════════════════════════════════════
|
|||
|
|
# 分析器
|
|||
|
|
# ══════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
class CoverageAnalyzer:
|
|||
|
|
|
|||
|
|
def __init__(
|
|||
|
|
self,
|
|||
|
|
interfaces: list[InterfaceInfo],
|
|||
|
|
requirements: list[str],
|
|||
|
|
test_cases: list[dict],
|
|||
|
|
run_results: list,
|
|||
|
|
):
|
|||
|
|
self.interfaces = interfaces
|
|||
|
|
self.requirements = requirements
|
|||
|
|
self.test_cases = test_cases
|
|||
|
|
self.run_results = run_results
|
|||
|
|
self._iface_map = {i.name: i for i in interfaces}
|
|||
|
|
self._result_map = {
|
|||
|
|
getattr(r, "test_id", ""): r for r in run_results
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# ── 入口 ──────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def analyze(self) -> CoverageReport:
|
|||
|
|
logger.info("Starting coverage analysis …")
|
|||
|
|
report = CoverageReport()
|
|||
|
|
|
|||
|
|
iface_cov_map = self._init_interface_coverages()
|
|||
|
|
req_cov_map = self._init_requirement_coverages()
|
|||
|
|
|
|||
|
|
self._scan_test_cases(iface_cov_map, req_cov_map)
|
|||
|
|
self._scan_run_results(report)
|
|||
|
|
|
|||
|
|
report.interface_coverages = list(iface_cov_map.values())
|
|||
|
|
report.requirement_coverages = list(req_cov_map.values())
|
|||
|
|
report.total_interfaces = len(self.interfaces)
|
|||
|
|
report.covered_interfaces = sum(
|
|||
|
|
1 for ic in iface_cov_map.values() if ic.is_covered
|
|||
|
|
)
|
|||
|
|
report.total_requirements = len(self.requirements)
|
|||
|
|
report.covered_requirements = sum(
|
|||
|
|
1 for rc in req_cov_map.values() if rc.is_covered
|
|||
|
|
)
|
|||
|
|
report.total_test_cases = len(self.test_cases)
|
|||
|
|
report.gaps = self._identify_gaps(iface_cov_map, req_cov_map)
|
|||
|
|
|
|||
|
|
logger.info(
|
|||
|
|
f"Analysis done — "
|
|||
|
|
f"interface={report.interface_coverage_rate:.0%}, "
|
|||
|
|
f"requirement={report.requirement_coverage_rate:.0%}, "
|
|||
|
|
f"pass={report.pass_rate:.0%}, "
|
|||
|
|
f"gaps={len(report.gaps)}"
|
|||
|
|
)
|
|||
|
|
return report
|
|||
|
|
|
|||
|
|
# ══════════════════════════════════════════════════════════
|
|||
|
|
# 初始化接口覆盖对象
|
|||
|
|
# ══════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
def _init_interface_coverages(self) -> dict[str, InterfaceCoverage]:
|
|||
|
|
result: dict[str, InterfaceCoverage] = {}
|
|||
|
|
|
|||
|
|
for iface in self.interfaces:
|
|||
|
|
ic = InterfaceCoverage(
|
|||
|
|
interface_name=iface.name,
|
|||
|
|
protocol=iface.protocol,
|
|||
|
|
requirement_id=iface.requirement_id,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ── 入参(in / inout)────────────────────────────
|
|||
|
|
# HTTP 与 function 统一使用 in_parameters
|
|||
|
|
ic.all_in_params = {p.name for p in iface.in_parameters}
|
|||
|
|
|
|||
|
|
# ── 出参(out / inout)───────────────────────────
|
|||
|
|
ic.all_out_params = {p.name for p in iface.out_parameters}
|
|||
|
|
|
|||
|
|
# ── 返回值字段(on_success)──────────────────────
|
|||
|
|
ri = iface.return_info
|
|||
|
|
success_fields = set(ri.on_success.value_fields.keys())
|
|||
|
|
|
|||
|
|
# boolean / 非 dict 类型:用虚拟字段 "return" 代表返回值本身
|
|||
|
|
if ri.type in ("boolean", "integer", "string", "float") or (
|
|||
|
|
ri.on_success.value is not None
|
|||
|
|
and not isinstance(ri.on_success.value, dict)
|
|||
|
|
):
|
|||
|
|
success_fields.add("return")
|
|||
|
|
|
|||
|
|
ic.all_success_fields = success_fields
|
|||
|
|
|
|||
|
|
# ── 返回值字段(on_failure)──────────────────────
|
|||
|
|
if ri.on_failure.is_exception:
|
|||
|
|
ic.expects_exception = True
|
|||
|
|
else:
|
|||
|
|
failure_fields = set(ri.on_failure.value_fields.keys())
|
|||
|
|
if ri.type in ("boolean", "integer", "string", "float") or (
|
|||
|
|
ri.on_failure.value is not None
|
|||
|
|
and not isinstance(ri.on_failure.value, dict)
|
|||
|
|
and not ri.on_failure.is_exception
|
|||
|
|
):
|
|||
|
|
failure_fields.add("return")
|
|||
|
|
ic.all_failure_fields = failure_fields
|
|||
|
|
|
|||
|
|
result[iface.name] = ic
|
|||
|
|
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
# ══════════════════════════════════════════════════════════
|
|||
|
|
# 初始化需求覆盖对象
|
|||
|
|
# ══════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
def _init_requirement_coverages(self) -> dict[str, RequirementCoverage]:
|
|||
|
|
result: dict[str, RequirementCoverage] = {}
|
|||
|
|
for req in self.requirements:
|
|||
|
|
result[req] = RequirementCoverage(requirement=req)
|
|||
|
|
|
|||
|
|
# requirement_id → requirement text 的辅助映射
|
|||
|
|
self._req_id_map: dict[str, str] = {}
|
|||
|
|
for iface in self.interfaces:
|
|||
|
|
if iface.requirement_id:
|
|||
|
|
matched = self._fuzzy_match_req(iface.name, result)
|
|||
|
|
if matched:
|
|||
|
|
self._req_id_map[iface.requirement_id] = matched
|
|||
|
|
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
# ══════════════════════════════════════════════════════════
|
|||
|
|
# 扫描测试用例
|
|||
|
|
# ══════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
def _scan_test_cases(
|
|||
|
|
self,
|
|||
|
|
iface_cov_map: dict[str, InterfaceCoverage],
|
|||
|
|
req_cov_map: dict[str, RequirementCoverage],
|
|||
|
|
):
|
|||
|
|
for tc in self.test_cases:
|
|||
|
|
test_id = tc.get("test_id", "")
|
|||
|
|
requirement = tc.get("requirement", "")
|
|||
|
|
req_id = tc.get("requirement_id", "")
|
|||
|
|
description = (
|
|||
|
|
tc.get("description", "") + " " + tc.get("test_name", "")
|
|||
|
|
).lower()
|
|||
|
|
|
|||
|
|
is_negative = any(
|
|||
|
|
kw in description
|
|||
|
|
for kw in (
|
|||
|
|
"negative", "invalid", "missing", "fail", "error",
|
|||
|
|
"wrong", "bad", "boundary", "负向", "exception",
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 需求覆盖匹配
|
|||
|
|
matched_req = (
|
|||
|
|
self._match_by_req_id(req_id, req_cov_map)
|
|||
|
|
or self._match_by_text(requirement, req_cov_map)
|
|||
|
|
)
|
|||
|
|
if matched_req:
|
|||
|
|
req_cov_map[matched_req].covering_test_ids.append(test_id)
|
|||
|
|
|
|||
|
|
# 遍历步骤
|
|||
|
|
for step in tc.get("steps", []):
|
|||
|
|
iface_name = step.get("interface_name", "")
|
|||
|
|
ic = iface_cov_map.get(iface_name)
|
|||
|
|
if ic is None:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
ic.is_covered = True
|
|||
|
|
if test_id not in ic.covering_test_ids:
|
|||
|
|
ic.covering_test_ids.append(test_id)
|
|||
|
|
|
|||
|
|
if is_negative:
|
|||
|
|
ic.has_negative_case = True
|
|||
|
|
else:
|
|||
|
|
ic.has_positive_case = True
|
|||
|
|
|
|||
|
|
# 入参覆盖
|
|||
|
|
for param_name in step.get("input", {}).keys():
|
|||
|
|
if param_name in ic.all_in_params:
|
|||
|
|
ic.covered_in_params.add(param_name)
|
|||
|
|
|
|||
|
|
# 断言覆盖
|
|||
|
|
for assertion in step.get("assertions", []):
|
|||
|
|
f = assertion.get("field", "")
|
|||
|
|
operator = assertion.get("operator", "")
|
|||
|
|
|
|||
|
|
# 异常断言
|
|||
|
|
if f == "exception" and operator == "raised":
|
|||
|
|
ic.exception_case_covered = True
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# 出参断言
|
|||
|
|
if f in ic.all_out_params:
|
|||
|
|
ic.asserted_out_params.add(f)
|
|||
|
|
|
|||
|
|
# on_success 字段(正向用例)
|
|||
|
|
if not is_negative and f in ic.all_success_fields:
|
|||
|
|
ic.covered_success_fields.add(f)
|
|||
|
|
|
|||
|
|
# on_failure 字段(负向用例)
|
|||
|
|
if is_negative and f in ic.all_failure_fields:
|
|||
|
|
ic.covered_failure_fields.add(f)
|
|||
|
|
|
|||
|
|
# boolean / scalar return 断言
|
|||
|
|
if f == "return":
|
|||
|
|
if not is_negative:
|
|||
|
|
ic.covered_success_fields.add("return")
|
|||
|
|
else:
|
|||
|
|
ic.covered_failure_fields.add("return")
|
|||
|
|
|
|||
|
|
# ══════════════════════════════════════════════════════════
|
|||
|
|
# 扫描执行结果
|
|||
|
|
# ══════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
def _scan_run_results(self, report: CoverageReport):
|
|||
|
|
for r in self.run_results:
|
|||
|
|
s = getattr(r, "status", "")
|
|||
|
|
if s == "PASS":
|
|||
|
|
report.passed_test_cases += 1
|
|||
|
|
elif s == "FAIL":
|
|||
|
|
report.failed_test_cases += 1
|
|||
|
|
else:
|
|||
|
|
report.error_test_cases += 1
|
|||
|
|
|
|||
|
|
# ══════════════════════════════════════════════════════════
|
|||
|
|
# 缺口识别
|
|||
|
|
# ══════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
def _identify_gaps(
|
|||
|
|
self,
|
|||
|
|
iface_cov_map: dict[str, InterfaceCoverage],
|
|||
|
|
req_cov_map: dict[str, RequirementCoverage],
|
|||
|
|
) -> list[Gap]:
|
|||
|
|
gaps: list[Gap] = []
|
|||
|
|
|
|||
|
|
for ic in iface_cov_map.values():
|
|||
|
|
n = ic.interface_name
|
|||
|
|
rid = f"[{ic.requirement_id}] " if ic.requirement_id else ""
|
|||
|
|
|
|||
|
|
# ① 接口完全未覆盖
|
|||
|
|
if not ic.is_covered:
|
|||
|
|
gaps.append(Gap(
|
|||
|
|
gap_type=GAP_INTERFACE_NOT_COVERED,
|
|||
|
|
severity="critical", target=n,
|
|||
|
|
detail=f"{rid}'{n}' has NO test case.",
|
|||
|
|
suggestion=f"Add positive + negative test cases for '{n}'.",
|
|||
|
|
))
|
|||
|
|
continue # 后续缺口依赖覆盖,跳过
|
|||
|
|
|
|||
|
|
# ② 缺少正向用例
|
|||
|
|
if not ic.has_positive_case:
|
|||
|
|
gaps.append(Gap(
|
|||
|
|
gap_type=GAP_MISSING_POSITIVE,
|
|||
|
|
severity="critical", target=n,
|
|||
|
|
detail=f"{rid}'{n}' has no positive (happy-path) test.",
|
|||
|
|
suggestion=f"Add a positive test case with valid inputs for '{n}'.",
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
# ③ 缺少负向用例
|
|||
|
|
if not ic.has_negative_case:
|
|||
|
|
gaps.append(Gap(
|
|||
|
|
gap_type=GAP_MISSING_NEGATIVE,
|
|||
|
|
severity="high", target=n,
|
|||
|
|
detail=f"{rid}'{n}' has no negative test case.",
|
|||
|
|
suggestion=(
|
|||
|
|
f"Add negative tests for '{n}': "
|
|||
|
|
f"missing required params, invalid types, boundary values."
|
|||
|
|
),
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
# ④ 入参未覆盖
|
|||
|
|
for param in sorted(ic.uncovered_in_params):
|
|||
|
|
gaps.append(Gap(
|
|||
|
|
gap_type=GAP_IN_PARAM_NOT_COVERED,
|
|||
|
|
severity="high", target=n,
|
|||
|
|
detail=f"{rid}Input param '{param}' of '{n}' never used in tests.",
|
|||
|
|
suggestion=f"Add a test that explicitly passes '{param}' to '{n}'.",
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
# ⑤ 出参未断言
|
|||
|
|
for param in sorted(ic.uncovered_out_params):
|
|||
|
|
gaps.append(Gap(
|
|||
|
|
gap_type=GAP_OUT_PARAM_NOT_ASSERTED,
|
|||
|
|
severity="high", target=n,
|
|||
|
|
detail=f"{rid}Output param '{param}' of '{n}' never asserted.",
|
|||
|
|
suggestion=(
|
|||
|
|
f"Add an assertion on output param '{param}' "
|
|||
|
|
f"after calling '{n}'."
|
|||
|
|
),
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
# ⑥ on_success 返回字段未断言
|
|||
|
|
for f in sorted(ic.uncovered_success_fields):
|
|||
|
|
gaps.append(Gap(
|
|||
|
|
gap_type=GAP_SUCCESS_FIELD_NOT_ASSERTED,
|
|||
|
|
severity="high", target=n,
|
|||
|
|
detail=(
|
|||
|
|
f"{rid}on_success field '{f}' of '{n}' "
|
|||
|
|
f"never asserted in positive tests."
|
|||
|
|
),
|
|||
|
|
suggestion=(
|
|||
|
|
f"Add assertion on '{f}' in the positive "
|
|||
|
|
f"test step for '{n}'."
|
|||
|
|
),
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
# ⑦ on_failure 返回字段未断言
|
|||
|
|
for f in sorted(ic.uncovered_failure_fields):
|
|||
|
|
gaps.append(Gap(
|
|||
|
|
gap_type=GAP_FAILURE_FIELD_NOT_ASSERTED,
|
|||
|
|
severity="medium", target=n,
|
|||
|
|
detail=(
|
|||
|
|
f"{rid}on_failure field '{f}' of '{n}' "
|
|||
|
|
f"never asserted in negative tests."
|
|||
|
|
),
|
|||
|
|
suggestion=(
|
|||
|
|
f"Add assertion on '{f}' in the negative "
|
|||
|
|
f"test step for '{n}'."
|
|||
|
|
),
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
# ⑧ 异常场景未测试
|
|||
|
|
if ic.expects_exception and not ic.exception_case_covered:
|
|||
|
|
gaps.append(Gap(
|
|||
|
|
gap_type=GAP_EXCEPTION_NOT_TESTED,
|
|||
|
|
severity="high", target=n,
|
|||
|
|
detail=(
|
|||
|
|
f"{rid}'{n}' declares on_failure = raises Exception, "
|
|||
|
|
f"but no test asserts that the exception is raised."
|
|||
|
|
),
|
|||
|
|
suggestion=(
|
|||
|
|
f"Add a negative test for '{n}' that wraps the call "
|
|||
|
|
f"in try/except and asserts the exception is raised."
|
|||
|
|
),
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
# ⑨ 需求未覆盖
|
|||
|
|
for rc in req_cov_map.values():
|
|||
|
|
if not rc.is_covered:
|
|||
|
|
gaps.append(Gap(
|
|||
|
|
gap_type=GAP_REQUIREMENT_NOT_COVERED,
|
|||
|
|
severity="critical",
|
|||
|
|
target=rc.requirement,
|
|||
|
|
detail=f"Requirement '{rc.requirement}' has no test case.",
|
|||
|
|
suggestion=f"Generate test cases for: '{rc.requirement}'.",
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
# ⑩ 执行失败 / 错误
|
|||
|
|
for r in self.run_results:
|
|||
|
|
s = getattr(r, "status", "")
|
|||
|
|
if s == "FAIL":
|
|||
|
|
gaps.append(Gap(
|
|||
|
|
gap_type=GAP_TEST_FAILED,
|
|||
|
|
severity="high",
|
|||
|
|
target=getattr(r, "test_id", ""),
|
|||
|
|
detail=f"Test '{r.test_id}' FAILED: {r.message}",
|
|||
|
|
suggestion=(
|
|||
|
|
"Investigate failure; fix implementation or test data."
|
|||
|
|
),
|
|||
|
|
))
|
|||
|
|
elif s in ("ERROR", "TIMEOUT"):
|
|||
|
|
gaps.append(Gap(
|
|||
|
|
gap_type=GAP_TEST_ERROR,
|
|||
|
|
severity="medium",
|
|||
|
|
target=getattr(r, "test_id", ""),
|
|||
|
|
detail=f"Test '{r.test_id}' {s}: {r.message}",
|
|||
|
|
suggestion=(
|
|||
|
|
"Check script for runtime errors or increase TEST_TIMEOUT."
|
|||
|
|
),
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
# 按严重程度排序
|
|||
|
|
_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|||
|
|
gaps.sort(key=lambda g: _order.get(g.severity, 9))
|
|||
|
|
return gaps
|
|||
|
|
|
|||
|
|
# ══════════════════════════════════════════════════════════
|
|||
|
|
# 工具方法
|
|||
|
|
# ══════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
def _match_by_req_id(
|
|||
|
|
self,
|
|||
|
|
req_id: str,
|
|||
|
|
req_cov_map: dict[str, RequirementCoverage],
|
|||
|
|
) -> str | None:
|
|||
|
|
if not req_id:
|
|||
|
|
return None
|
|||
|
|
mapped = self._req_id_map.get(req_id)
|
|||
|
|
if mapped and mapped in req_cov_map:
|
|||
|
|
return mapped
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def _match_by_text(
|
|||
|
|
self,
|
|||
|
|
requirement: str,
|
|||
|
|
req_cov_map: dict[str, RequirementCoverage],
|
|||
|
|
) -> str | None:
|
|||
|
|
if requirement in req_cov_map:
|
|||
|
|
return requirement
|
|||
|
|
req_lower = requirement.lower()
|
|||
|
|
for key in req_cov_map:
|
|||
|
|
if key.lower() in req_lower or req_lower in key.lower():
|
|||
|
|
return key
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def _fuzzy_match_req(
|
|||
|
|
self,
|
|||
|
|
iface_name: str,
|
|||
|
|
req_cov_map: dict[str, RequirementCoverage],
|
|||
|
|
) -> str | None:
|
|||
|
|
name_lower = iface_name.lower().replace("_", " ")
|
|||
|
|
for key in req_cov_map:
|
|||
|
|
if name_lower in key.lower() or key.lower() in name_lower:
|
|||
|
|
return key
|
|||
|
|
return None
|