- 新建 pytest.ini 统一 test discovery(tests/ + skills/ir_generation_skill/tests/) - test_step1~3 转换为 pytest 兼容格式,无输出文件时自动 skip - 新增 tests/test_detect_conflicts.py(18 个纯函数单测) - 新增 tests/test_config.py(7 个配置模块单测) - CI 改为 pytest -v 使用 pytest.ini testpaths - DEV_AGENT.md 新增 PR 提交规范 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -258,21 +258,27 @@ def acceptance_runs(request) -> int:
|
||||
def run_ir_pipeline():
|
||||
"""Return a callable that runs the IR generation pipeline on a parsed JSON.
|
||||
|
||||
Returns None if the pipeline script is not available in the current environment.
|
||||
This is common when the acceptance tests run on pre-generated IR output.
|
||||
|
||||
Usage::
|
||||
|
||||
ir_data, ir_path = run_ir_pipeline(parsed_json_path, output_dir)
|
||||
runner = run_ir_pipeline()
|
||||
if runner:
|
||||
ir_data, ir_path = runner(parsed_json_path, output_dir)
|
||||
"""
|
||||
sys.path.insert(0, _skill_path("ir_generation_skill"))
|
||||
ir_gen_path = (
|
||||
_PROJECT_ROOT / "skills" / "ir_generation_skill" / "scripts" / "ir_generator.py"
|
||||
)
|
||||
if not ir_gen_path.exists():
|
||||
return None
|
||||
|
||||
sys.path.insert(0, str(ir_gen_path.parent))
|
||||
from ir_generator import generate_ir
|
||||
|
||||
def _run(parsed_path: str, output_dir: str | None = None) -> tuple[dict, str]:
|
||||
"""Run IR generation and return (ir_data, ir_path)."""
|
||||
def _run(parsed_path: str, output_dir: str | None = None) -> tuple[list, str]:
|
||||
out = output_dir or tempfile.mkdtemp(prefix="qe_acceptance_")
|
||||
result = generate_ir(parsed_path, out, dry_run=False)
|
||||
ir_list = result.get("ir", [])
|
||||
ir_path = result.get("path", "")
|
||||
# ir_generator produces a list; wrap to match rich format expectations
|
||||
# for schema validation we accept both formats
|
||||
return ir_list, ir_path
|
||||
return result.get("ir", []), result.get("path", "")
|
||||
|
||||
return _run
|
||||
|
||||
@@ -57,18 +57,28 @@ def test_layer_a_schema(ir_data: dict, request):
|
||||
NON_FUNCTIONAL_PATTERNS = [
|
||||
re.compile(p) for p in [
|
||||
r"编制.*变更.*日志",
|
||||
r"变更日志",
|
||||
r"文档背景",
|
||||
r"文档范围",
|
||||
r"术语解释",
|
||||
r"参考",
|
||||
r"参考(文献|文档|资料)?",
|
||||
r"附录",
|
||||
r"版本",
|
||||
r"变更记录",
|
||||
r"目录",
|
||||
r"前言",
|
||||
r"概述",
|
||||
r"简介",
|
||||
r"概述.*背景",
|
||||
r"产品简介",
|
||||
r"场景.*(说明|概述)",
|
||||
r".*概要说明$",
|
||||
r"相关文档",
|
||||
r"行业规范",
|
||||
r"政策法规",
|
||||
r"非功能说明",
|
||||
r"背景介绍",
|
||||
r"PRD", # document title like "XX Auto XXX PRD V1.0"
|
||||
r"产品架构", # architecture overview
|
||||
r"系统架构",
|
||||
]
|
||||
]
|
||||
|
||||
@@ -76,15 +86,20 @@ NON_FUNCTIONAL_PATTERNS = [
|
||||
def _is_functional_section(section_name: str) -> bool:
|
||||
"""Heuristic: exclude background, glossary, changelog, scope sections.
|
||||
|
||||
Sections that are purely structural — preface, glossary, changelog — are excluded.
|
||||
Sections with numbering like '3.1.1' are always considered functional.
|
||||
Check non-functional patterns first, then treat numbered sections (like
|
||||
'3.1.1 系统限制') as likely functional.
|
||||
"""
|
||||
# Numbered sections are functional
|
||||
if _section_number(section_name) != section_name:
|
||||
return True
|
||||
# Explicitly non-functional patterns (checked first)
|
||||
for pat in NON_FUNCTIONAL_PATTERNS:
|
||||
if pat.search(section_name):
|
||||
return False
|
||||
# Documents with only a title (no section number) — check for functional keywords
|
||||
sec_num = _section_number(section_name)
|
||||
if "." not in sec_num and not sec_num[0].isdigit():
|
||||
func_keywords = ["策略", "规则", "功能", "限制", "流程", "配置", "场景",
|
||||
"约束", "条件", "方案", "逻辑", "处理", "机制", "禁止"]
|
||||
if not any(kw in section_name for kw in func_keywords):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -263,19 +278,20 @@ def test_layer_b_coverage(
|
||||
stability_values: list[float] = [cov["overall_rate"]]
|
||||
stability_std = 0.0
|
||||
|
||||
if acceptance_runs > 1:
|
||||
if acceptance_runs > 1 and run_ir_pipeline is not None:
|
||||
parsed_path = request.config.getoption("--parsed-path")
|
||||
if parsed_path and os.path.exists(parsed_path):
|
||||
for _ in range(acceptance_runs - 1):
|
||||
try:
|
||||
ir_list, _ = run_ir_pipeline(parsed_path)
|
||||
# Convert list-format IR to dict for coverage measurement
|
||||
run_ir = _wrap_list_ir(ir_list)
|
||||
run_cov = _measure_coverage(run_ir, parsed_data)
|
||||
stability_values.append(run_cov["overall_rate"])
|
||||
time.sleep(0.5) # rate limiting between runs
|
||||
time.sleep(0.5)
|
||||
except Exception as e:
|
||||
pytest.fail(f"Stability run failed: {e}")
|
||||
elif acceptance_runs > 1 and run_ir_pipeline is None:
|
||||
print(" [Layer B] Stability testing skipped: pipeline runner not available")
|
||||
|
||||
if len(stability_values) > 1:
|
||||
stability_std = statistics.stdev(stability_values)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Unit tests for config.py pure functions."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "ir_generation_skill"))
|
||||
import config
|
||||
|
||||
|
||||
def test_set_input_file():
|
||||
original = config.INPUT_JSON
|
||||
try:
|
||||
config.set_input_file("/tmp/test_input.json")
|
||||
assert config.INPUT_JSON == "/tmp/test_input.json"
|
||||
finally:
|
||||
config.set_input_file(original)
|
||||
|
||||
|
||||
def test_config_constants_exist():
|
||||
"""Verify all expected path constants are defined."""
|
||||
assert config.BASE_DIR
|
||||
assert config.OUTPUT_DIR
|
||||
assert config.PROMPTS_DIR
|
||||
assert config.TESTS_DIR
|
||||
assert config.DOC_PARSER_OUTPUT
|
||||
assert config.SEMANTIC_INDEX_JSON
|
||||
assert config.IR_FRAGMENTS_JSON
|
||||
assert config.PATH_ENUM_JSON
|
||||
assert config.IR_AUTOCOMPLETE_FRAGMENTS_JSON
|
||||
assert config.IR_FINAL_JSON
|
||||
assert config.IR_AUDIT_REPORT_MD
|
||||
|
||||
|
||||
def test_ensemble_temperatures_count():
|
||||
"""Should have exactly 3 ensemble temperatures."""
|
||||
assert len(config.ENSEMBLE_TEMPERATURES) == 3
|
||||
|
||||
|
||||
def test_max_tokens_is_int():
|
||||
assert isinstance(config.MAX_TOKENS, int)
|
||||
assert config.MAX_TOKENS > 0
|
||||
|
||||
|
||||
def test_temperature_is_float():
|
||||
assert isinstance(config.TEMPERATURE, float)
|
||||
assert 0.0 <= config.TEMPERATURE <= 2.0
|
||||
|
||||
|
||||
def test_provider_models_has_expected_keys():
|
||||
assert "deepseek" in config.PROVIDER_MODELS
|
||||
assert "dashscope" in config.PROVIDER_MODELS
|
||||
|
||||
|
||||
def test_model_name_in_provider_models():
|
||||
assert config.MODEL_NAME in config.PROVIDER_MODELS.values()
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Unit tests for pure functions in detect_conflicts.py — no LLM calls, no file I/O."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Add detect_conflicts script to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "conflict_detection_skill" / "scripts"))
|
||||
import detect_conflicts as dc
|
||||
|
||||
|
||||
# ── _is_nested_tree ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_is_nested_tree_with_children_list():
|
||||
assert dc._is_nested_tree({"children": []}) is True
|
||||
|
||||
|
||||
def test_is_nested_tree_without_children_list():
|
||||
assert dc._is_nested_tree({"nodes": []}) is False
|
||||
|
||||
|
||||
def test_is_nested_tree_empty_dict():
|
||||
assert dc._is_nested_tree({}) is False
|
||||
|
||||
|
||||
# ── _nested_tree_to_text ─────────────────────────────────────────────────────
|
||||
|
||||
def test_nested_tree_simple():
|
||||
tree = {
|
||||
"id": "N1",
|
||||
"name": "开始",
|
||||
"type": "start",
|
||||
"children": [
|
||||
{
|
||||
"id": "N2",
|
||||
"name": "判断条件",
|
||||
"type": "decision",
|
||||
"children": [
|
||||
{
|
||||
"condition": "是",
|
||||
"node": {
|
||||
"id": "N3",
|
||||
"name": "执行动作",
|
||||
"type": "action",
|
||||
},
|
||||
},
|
||||
{
|
||||
"condition": "否",
|
||||
"node": {
|
||||
"id": "N4",
|
||||
"name": "结束",
|
||||
"type": "end",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
result = dc._nested_tree_to_text(tree)
|
||||
assert "[起始] N1: 开始" in result
|
||||
assert "[判断] N2: 判断条件" in result
|
||||
assert '分支 "是":' in result
|
||||
assert "[动作] N3: 执行动作" in result
|
||||
assert '分支 "否":' in result
|
||||
assert "[结束] N4: 结束" in result
|
||||
|
||||
|
||||
def test_nested_tree_empty_children():
|
||||
tree = {"id": "N1", "name": "结束", "type": "end"}
|
||||
result = dc._nested_tree_to_text(tree)
|
||||
assert "[结束] N1: 结束" in result
|
||||
|
||||
|
||||
# ── _flat_tree_to_text ───────────────────────────────────────────────────────
|
||||
|
||||
def test_flat_tree_with_decision_and_action():
|
||||
lt = {
|
||||
"root": "START",
|
||||
"nodes": [
|
||||
{"id": "D1", "type": "decision", "condition": "车速>15",
|
||||
"branches": [{"value": "是", "target": "A1"}, {"value": "否", "target": "END"}]},
|
||||
{"id": "A1", "type": "action", "description": "禁止视频播放"},
|
||||
{"id": "END", "type": "end", "description": "结束"},
|
||||
],
|
||||
}
|
||||
result = dc._flat_tree_to_text(lt)
|
||||
assert "根节点: START" in result
|
||||
assert '判断节点 D1: 条件="车速>15"' in result
|
||||
assert '分支 "是" → A1' in result
|
||||
assert "动作节点 A1: 禁止视频播放" in result
|
||||
|
||||
|
||||
def test_flat_tree_empty():
|
||||
result = dc._flat_tree_to_text({})
|
||||
assert result == ""
|
||||
|
||||
|
||||
# ── _logic_tree_to_text (dispatcher) ─────────────────────────────────────────
|
||||
|
||||
def test_logic_tree_to_text_nested():
|
||||
lt = {"children": [{"id": "N1", "name": "开始", "type": "start"}]}
|
||||
result = dc._logic_tree_to_text(lt)
|
||||
assert "[起始] N1: 开始" in result
|
||||
|
||||
|
||||
def test_logic_tree_to_text_flat():
|
||||
lt = {"nodes": [{"id": "A1", "type": "action", "description": "测试"}]}
|
||||
result = dc._logic_tree_to_text(lt)
|
||||
assert "动作节点 A1: 测试" in result
|
||||
|
||||
|
||||
# ── _parse_conflict_json ─────────────────────────────────────────────────────
|
||||
|
||||
def test_parse_no_conflict():
|
||||
assert dc._parse_conflict_json("[[NO_CONFLICT]]") == []
|
||||
assert dc._parse_conflict_json("some text [[NO_CONFLICT]] more") == []
|
||||
|
||||
|
||||
def test_parse_conflict_valid_json():
|
||||
content = json.dumps([
|
||||
{"conflict_type": "condition_mismatch", "severity": "high",
|
||||
"section": "3.1", "image_snippet": "img", "text_snippet": "txt",
|
||||
"description": "冲突描述"}
|
||||
], ensure_ascii=False)
|
||||
result = dc._parse_conflict_json(content)
|
||||
assert len(result) == 1
|
||||
assert result[0]["conflict_type"] == "condition_mismatch"
|
||||
|
||||
|
||||
def test_parse_conflict_with_markdown_fence():
|
||||
content = '```json\n[{"conflict_type": "contradiction", "severity": "high", "section": "1.0", "image_snippet": "a", "text_snippet": "b", "description": "x"}]\n```'
|
||||
result = dc._parse_conflict_json(content)
|
||||
assert len(result) == 1
|
||||
assert result[0]["conflict_type"] == "contradiction"
|
||||
|
||||
|
||||
def test_parse_conflict_unparseable():
|
||||
assert dc._parse_conflict_json("not valid json at all") == []
|
||||
assert dc._parse_conflict_json("") == []
|
||||
|
||||
|
||||
def test_parse_conflict_mixed_content():
|
||||
content = 'some prefix text\n[\n {"conflict_type": "scope_mismatch", "severity": "low", "section": "4.2", "image_snippet": "img", "text_snippet": "txt", "description": "d"}]\nsuffix'
|
||||
result = dc._parse_conflict_json(content)
|
||||
assert len(result) == 1
|
||||
assert result[0]["conflict_type"] == "scope_mismatch"
|
||||
|
||||
|
||||
# ── _build_text_for_section ──────────────────────────────────────────────────
|
||||
|
||||
def test_build_text_para_blocks():
|
||||
sections = [
|
||||
{"source": "3.1 测试章节",
|
||||
"blocks": [
|
||||
{"type": "para", "text": "第一段文字"},
|
||||
{"type": "para", "text": "第二段文字"},
|
||||
]},
|
||||
]
|
||||
result = dc._build_text_for_section(sections, "3.1 测试章节")
|
||||
assert "第一段文字" in result
|
||||
assert "第二段文字" in result
|
||||
|
||||
|
||||
def test_build_text_table_blocks():
|
||||
sections = [
|
||||
{"source": "4.0 功能列表",
|
||||
"blocks": [
|
||||
{"type": "table", "table": "功能表",
|
||||
"rows": [
|
||||
{"columns": [{"name": "功能", "text": "视频播放"}, {"name": "状态", "text": "禁止"}]},
|
||||
]},
|
||||
]},
|
||||
]
|
||||
result = dc._build_text_for_section(sections, "4.0 功能列表")
|
||||
assert "功能表" in result
|
||||
assert "视频播放" in result
|
||||
assert "禁止" in result
|
||||
|
||||
|
||||
def test_build_text_section_not_found():
|
||||
sections = [{"source": "1.0 概述", "blocks": []}]
|
||||
result = dc._build_text_for_section(sections, "2.0 不存在的章节")
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_build_text_mixed_blocks():
|
||||
sections = [
|
||||
{"source": "5.1 混合章节",
|
||||
"blocks": [
|
||||
{"type": "para", "text": "介绍文字"},
|
||||
{"type": "table", "table": "表1",
|
||||
"rows": [{"columns": [{"name": "A", "text": "值1"}]}]},
|
||||
]},
|
||||
]
|
||||
result = dc._build_text_for_section(sections, "5.1 混合章节")
|
||||
assert "介绍文字" in result
|
||||
assert "表1" in result
|
||||
assert "值1" in result
|
||||
Reference in New Issue
Block a user