feat(impact): Impact Agent MVP —— 变更点定位 + 影响调查书(追加改修场景)
- 门控:用户提供 existing_system 路径 → 进入影响调查;未提供 → 原流程不变
- CodeParser 解析 Java(@RestController/@Service/@Entity/@TableName)+ ExistingSystemExplorer 组装
- ImpactAgent 变更点定位(变更区分×既存対応 确定性比对,无 LLM)→ ImpactReport(JSON 可下载)
- 影响调查结果作为 Writer 生成概要设计书的主上下文({{impact}},无专用影响章)
- source_aggregator 解除 existing_system=None 硬编码
- 既有系统样本 sunOnly/stock-trade-system(无 LICENSE,仅测试输入,保留来源标注)
- 新造股票交易域追加改修样本 要件定義_追加改修_股票.xlsx(对齐 sunOnly 真实类名)
- 全量 351 passed / 99.27% 覆盖;门禁 PASS(16 要素:新规5/変更8/削除3/未受影响50)
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
"""CodeParser 测试(Impact Agent MVP):Java 项目解析 → CodeStructure。"""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from genesis.impact.code_parser import CodeParseError, CodeParser
|
||||
|
||||
FIXTURE_JAVA = {
|
||||
"trade-order/src/main/java/com/trade/order/controller/OrderController.java": (
|
||||
"package com.trade.order.controller;\n"
|
||||
"import org.springframework.web.bind.annotation.*;\n"
|
||||
"@RestController\n"
|
||||
"@RequestMapping(\"/api/order\")\n"
|
||||
"public class OrderController {\n"
|
||||
" @GetMapping(\"/{id}\")\n"
|
||||
" public String getOrder(@PathVariable Long id) { return \"ok\"; }\n"
|
||||
" @PostMapping(\"/create\")\n"
|
||||
" public String createOrder() { return \"ok\"; }\n"
|
||||
"}\n"
|
||||
),
|
||||
"trade-order/src/main/java/com/trade/order/service/OrderQueryService.java": (
|
||||
"package com.trade.order.service;\n"
|
||||
"import org.springframework.stereotype.Service;\n"
|
||||
"@Service\n"
|
||||
"public class OrderQueryService {\n"
|
||||
" public void query() {}\n"
|
||||
" public void update() {}\n"
|
||||
"}\n"
|
||||
),
|
||||
"trade-order/src/main/java/com/trade/order/entity/OrderEntity.java": (
|
||||
"package com.trade.order.entity;\n"
|
||||
"import javax.persistence.Entity;\n"
|
||||
"import javax.persistence.Table;\n"
|
||||
"@Entity\n"
|
||||
"@Table(name = \"t_order\")\n"
|
||||
"public class OrderEntity {\n"
|
||||
" private Long id;\n"
|
||||
" private String orderNo;\n"
|
||||
"}\n"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _make_project(tmp_path, files: dict[str, str]) -> Path:
|
||||
for rel, content in files.items():
|
||||
p = tmp_path / rel
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(content, encoding="utf-8")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_parse_controller_detected(tmp_path):
|
||||
root = _make_project(tmp_path, FIXTURE_JAVA)
|
||||
result = CodeParser().parse(root)
|
||||
ctrls = {c.class_name: c for c in result.controllers}
|
||||
oc = ctrls["OrderController"]
|
||||
assert oc.base_path == "/api/order"
|
||||
assert "/api/order/{id}" in oc.endpoints
|
||||
assert "/api/order/create" in oc.endpoints
|
||||
assert oc.source_uri.endswith("OrderController.java")
|
||||
|
||||
|
||||
def test_parse_service_detected(tmp_path):
|
||||
root = _make_project(tmp_path, FIXTURE_JAVA)
|
||||
result = CodeParser().parse(root)
|
||||
svcs = {s.class_name: s for s in result.services}
|
||||
oq = svcs["OrderQueryService"]
|
||||
assert "query" in oq.methods
|
||||
assert "update" in oq.methods
|
||||
|
||||
|
||||
def test_parse_entity_detected(tmp_path):
|
||||
root = _make_project(tmp_path, FIXTURE_JAVA)
|
||||
result = CodeParser().parse(root)
|
||||
ents = {e.class_name: e for e in result.entities}
|
||||
oe = ents["OrderEntity"]
|
||||
assert oe.table_name == "t_order"
|
||||
assert "id" in oe.fields
|
||||
assert "orderNo" in oe.fields
|
||||
|
||||
|
||||
def test_parse_endpoints_detected(tmp_path):
|
||||
root = _make_project(tmp_path, FIXTURE_JAVA)
|
||||
result = CodeParser().parse(root)
|
||||
paths = {(e.method, e.path) for e in result.endpoints}
|
||||
assert ("GET", "/api/order/{id}") in paths
|
||||
assert ("POST", "/api/order/create") in paths
|
||||
assert all(e.controller == "OrderController" for e in result.endpoints)
|
||||
|
||||
|
||||
def test_parse_modules_top_level_dirs(tmp_path):
|
||||
root = _make_project(tmp_path, FIXTURE_JAVA)
|
||||
result = CodeParser().parse(root)
|
||||
assert "trade-order" in result.modules
|
||||
assert result.language == "java"
|
||||
|
||||
|
||||
def test_parse_no_java_raises(tmp_path):
|
||||
(tmp_path / "readme.txt").write_text("hello", encoding="utf-8")
|
||||
with pytest.raises(CodeParseError):
|
||||
CodeParser().parse(tmp_path)
|
||||
|
||||
|
||||
def test_parse_missing_root_raises(tmp_path):
|
||||
with pytest.raises(CodeParseError):
|
||||
CodeParser().parse(tmp_path / "nope")
|
||||
|
||||
|
||||
def test_parse_real_sample_has_order_controller():
|
||||
root = Path(__file__).resolve().parents[1] / "samples" / "existing-system"
|
||||
if not root.exists():
|
||||
pytest.skip("样本缺失")
|
||||
result = CodeParser().parse(root)
|
||||
names = {c.class_name for c in result.controllers}
|
||||
assert "OrderController" in names
|
||||
assert any(c.base_path for c in result.controllers)
|
||||
|
||||
|
||||
def test_parse_real_sample_has_entities_and_endpoints():
|
||||
root = Path(__file__).resolve().parents[1] / "samples" / "existing-system"
|
||||
if not root.exists():
|
||||
pytest.skip("样本缺失")
|
||||
result = CodeParser().parse(root)
|
||||
assert result.entities, "既有系统样本应至少有一个实体类"
|
||||
assert result.endpoints, "既有系统样本应至少有一个端点"
|
||||
assert result.services, "既有系统样本应至少有一个 @Service 类"
|
||||
@@ -1,8 +1,9 @@
|
||||
from dataclasses import asdict
|
||||
|
||||
from genesis.data_models import (
|
||||
CellComment, CellFormatting, CellValue, Confidence, ElementType,
|
||||
ExcelTable, ExtractionMethod, ImageAnalysis, ParsedTemplate, Provenance,
|
||||
CellComment, CellFormatting, CellValue, ChangeAnalysis, ChangeElement,
|
||||
ChangeType, Confidence, ElementType, ExcelTable, ExtractionMethod,
|
||||
ImageAnalysis, ImpactReport, ImpactWarning, ParsedTemplate, Provenance,
|
||||
RelationType, RuleDocument, SheetType, StructuredSource,
|
||||
)
|
||||
|
||||
@@ -75,3 +76,56 @@ def test_asdict_serializable():
|
||||
prov = Provenance(file_name="f.xlsx", sheet_name="S", row=1, column="A", column_header="h")
|
||||
d = asdict(CellValue(value=1, provenance=prov))
|
||||
assert d["provenance"]["row"] == 1
|
||||
|
||||
|
||||
# ---------- Impact Agent MVP 数据模型(2026-08-23) ----------
|
||||
|
||||
def test_changetype_members():
|
||||
assert ChangeType.NEW.value == "新規"
|
||||
assert ChangeType.MODIFIED.value == "変更"
|
||||
assert ChangeType.DELETED.value == "削除"
|
||||
assert ChangeType.UNCHANGED.value == "不变"
|
||||
|
||||
|
||||
def test_change_element_defaults():
|
||||
el = ChangeElement(element_id="F001", element_type="機能", name="止损风控", change_type=ChangeType.NEW)
|
||||
assert el.existing_mapping == []
|
||||
assert el.impacted_existing == []
|
||||
assert el.evidence == ""
|
||||
assert el.status == "ok"
|
||||
|
||||
|
||||
def test_change_analysis_holds_lists():
|
||||
ca = ChangeAnalysis(
|
||||
project_type="enhancement",
|
||||
new_elements=[ChangeElement("F001", "機能", "止损风控", ChangeType.NEW)],
|
||||
modified_elements=[],
|
||||
deleted_elements=[],
|
||||
unchanged_elements=[],
|
||||
warnings=[ImpactWarning(element_id="F002", issue="无法定位修改对象")],
|
||||
)
|
||||
assert ca.project_type == "enhancement"
|
||||
assert ca.new_elements[0].element_id == "F001"
|
||||
assert ca.warnings[0].issue == "无法定位修改对象"
|
||||
|
||||
|
||||
def test_impact_report_defaults():
|
||||
report = ImpactReport(metadata={"version": "v1"})
|
||||
assert report.change_analysis is None
|
||||
assert report.summary == {}
|
||||
|
||||
|
||||
def test_impact_report_holds_analysis():
|
||||
ca = ChangeAnalysis(project_type="enhancement", new_elements=[], modified_elements=[],
|
||||
deleted_elements=[], unchanged_elements=[], warnings=[])
|
||||
report = ImpactReport(metadata={"version": "v1"}, change_analysis=ca, summary={"new": 0})
|
||||
assert report.change_analysis.project_type == "enhancement"
|
||||
assert report.summary["new"] == 0
|
||||
|
||||
|
||||
def test_structured_source_impact_report_default_none():
|
||||
"""StructuredSource 新增 impact_report 字段,缺省 None(门控未提供时保持 None)。"""
|
||||
source = StructuredSource(
|
||||
tables=[], template=None, rule_docs=[], image_analyses=[], existing_system=None, comments=[],
|
||||
)
|
||||
assert source.impact_report is None
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""ExistingSystemExplorer 测试:CodeStructure → ExistingSystemInfo 分层组装。"""
|
||||
import pytest
|
||||
|
||||
from genesis.data_models import CodeStructure, ControllerInfo, EntityInfo, ServiceInfo
|
||||
from genesis.impact.existing_system_explorer import ExistingSystemExplorer
|
||||
|
||||
|
||||
def _code() -> CodeStructure:
|
||||
return CodeStructure(
|
||||
root_path="/fake/root",
|
||||
language="java",
|
||||
modules=["trade-order"],
|
||||
classes=[],
|
||||
controllers=[
|
||||
ControllerInfo(name="OrderController", class_name="OrderController", path="OrderController.java",
|
||||
base_path="/api/order", endpoints=["/api/order/{id}"], source_uri="OrderController.java"),
|
||||
],
|
||||
services=[ServiceInfo(name="OrderService", class_name="OrderService", path="OrderService.java",
|
||||
methods=["query"], source_uri="OrderService.java")],
|
||||
entities=[EntityInfo(name="OrderEntity", class_name="OrderEntity", path="OrderEntity.java",
|
||||
table_name="t_order", fields=["id"], source_uri="OrderEntity.java")],
|
||||
endpoints=[],
|
||||
raw_imports=[],
|
||||
)
|
||||
|
||||
|
||||
def test_explore_assembles_layers():
|
||||
info = ExistingSystemExplorer().explore(_code())
|
||||
assert len(info.controller_layer) == 1
|
||||
assert info.controller_layer[0].class_name == "OrderController"
|
||||
assert len(info.service_layer) == 1
|
||||
assert info.service_layer[0].class_name == "OrderService"
|
||||
assert len(info.entity_layer) == 1
|
||||
assert info.entity_layer[0].table_name == "t_order"
|
||||
assert info.source_path == "/fake/root"
|
||||
|
||||
|
||||
def test_explore_empty_code():
|
||||
code = CodeStructure(root_path="/x", language="java", modules=[], classes=[], controllers=[],
|
||||
services=[], entities=[], endpoints=[], raw_imports=[])
|
||||
info = ExistingSystemExplorer().explore(code)
|
||||
assert info.controller_layer == []
|
||||
assert info.service_layer == []
|
||||
assert info.entity_layer == []
|
||||
assert info.api_endpoints == []
|
||||
@@ -0,0 +1,203 @@
|
||||
"""ImpactAgent 变更点定位测试(MVP 确定性规则)。"""
|
||||
import pytest
|
||||
|
||||
from genesis.data_models import (
|
||||
CellValue, ChangeType, ControllerInfo, EntityInfo, ExcelTable,
|
||||
ExistingSystemInfo, Provenance, ServiceInfo, SheetType, StructuredSource,
|
||||
)
|
||||
from genesis.impact.impact_agent import ImpactAgent
|
||||
|
||||
|
||||
def _cv(value) -> CellValue:
|
||||
return CellValue(value=value, provenance=Provenance("f.xlsx", "S", 1, "A", "h"))
|
||||
|
||||
|
||||
def _row(*values: str) -> dict[str, CellValue]:
|
||||
headers = ["要素ID", "要素名", "変更区分", "既存対応"]
|
||||
return {h: _cv(v) for h, v in zip(headers, values)}
|
||||
|
||||
|
||||
def _table(name: str, stype: SheetType, rows: list[dict]) -> ExcelTable:
|
||||
return ExcelTable(name=name, detected_type=stype, extraction_method="openpyxl",
|
||||
headers=["要素ID", "要素名", "変更区分", "既存対応"], rows=rows)
|
||||
|
||||
|
||||
def _existing() -> ExistingSystemInfo:
|
||||
return ExistingSystemInfo(
|
||||
controller_layer=[
|
||||
ControllerInfo("OrderController", "OrderController", "OrderController.java", "/trade/order",
|
||||
["/trade/order/{id}"], "trade-order/OrderController.java"),
|
||||
ControllerInfo("RealtimeMarketDataController", "RealtimeMarketDataController", "R.java", "/market-data/realtime",
|
||||
[], "trade-market-data/R.java"),
|
||||
],
|
||||
service_layer=[
|
||||
ServiceInfo("OrderServiceImpl", "OrderServiceImpl", "OrderServiceImpl.java", ["createOrder"], "trade-order/OrderServiceImpl.java"),
|
||||
],
|
||||
entity_layer=[
|
||||
EntityInfo("OrderDO", "OrderDO", "OrderDO.java", "trade_order", ["id"], "trade-order/OrderDO.java"),
|
||||
EntityInfo("StrategyBacktest", "StrategyBacktest", "SB.java", "ts_strategy_backtest", [], "trade-strategy/SB.java"),
|
||||
],
|
||||
api_endpoints=[],
|
||||
source_path="samples/existing-system",
|
||||
)
|
||||
|
||||
|
||||
def _ss(existing: ExistingSystemInfo, tables: list[ExcelTable]) -> StructuredSource:
|
||||
return StructuredSource(tables=tables, template=None, rule_docs=[],
|
||||
image_analyses=[], existing_system=existing, comments=[])
|
||||
|
||||
|
||||
def test_classify_new_modified_deleted():
|
||||
tables = [
|
||||
_table("機能一覧", SheetType.FUNCTION, [
|
||||
_row("F001", "止损风控机能", "新規", ""),
|
||||
_row("F002", "订单状态查询扩展", "変更", "OrderController,OrderServiceImpl"),
|
||||
_row("F003", "实时行情显示扩展", "変更", "RealtimeMarketDataController"),
|
||||
_row("F004", "旧回测机能废止", "削除", "StrategyBacktest"),
|
||||
]),
|
||||
]
|
||||
report = ImpactAgent().run(_ss(_existing(), tables))
|
||||
ca = report.change_analysis
|
||||
assert ca.project_type == "enhancement"
|
||||
assert [e.element_id for e in ca.new_elements] == ["F001"]
|
||||
assert ca.new_elements[0].change_type is ChangeType.NEW
|
||||
assert [e.element_id for e in ca.modified_elements] == ["F002", "F003"]
|
||||
m2 = ca.modified_elements[0]
|
||||
assert m2.impacted_existing == ["OrderController", "OrderServiceImpl"]
|
||||
assert m2.evidence # 命中类 source_uri
|
||||
assert [e.element_id for e in ca.deleted_elements] == ["F004"]
|
||||
assert ca.deleted_elements[0].impacted_existing == ["StrategyBacktest"]
|
||||
assert ca.warnings == []
|
||||
|
||||
|
||||
def test_prefix_mapping_matches_impl_and_entity():
|
||||
tables = [_table("機能一覧", SheetType.FUNCTION, [
|
||||
_row("F005", "订单查询", "変更", "OrderService"), # 前缀匹配 OrderServiceImpl
|
||||
_row("F006", "订单表变更", "変更", "OrderDO"),
|
||||
])]
|
||||
ca = ImpactAgent().run(_ss(_existing(), tables)).change_analysis
|
||||
m = {e.element_id: e for e in ca.modified_elements}
|
||||
assert "OrderServiceImpl" in m["F005"].impacted_existing
|
||||
assert m["F006"].impacted_existing == ["OrderDO"]
|
||||
|
||||
|
||||
def test_new_with_mapping_is_conflict_warning():
|
||||
tables = [_table("機能一覧", SheetType.FUNCTION, [_row("F007", "新建订单接口", "新規", "OrderController")])]
|
||||
report = ImpactAgent().run(_ss(_existing(), tables))
|
||||
el = report.change_analysis.new_elements[0]
|
||||
assert el.status == "conflict"
|
||||
assert any(w.element_id == "F007" for w in report.change_analysis.warnings)
|
||||
|
||||
|
||||
def test_modified_without_mapping_warns():
|
||||
tables = [_table("機能一覧", SheetType.FUNCTION, [_row("F008", "订单变更", "変更", "")])]
|
||||
report = ImpactAgent().run(_ss(_existing(), tables))
|
||||
assert report.change_analysis.modified_elements[0].status == "warning"
|
||||
assert any(w.element_id == "F008" for w in report.change_analysis.warnings)
|
||||
|
||||
|
||||
def test_modified_mapping_no_match_warns():
|
||||
tables = [_table("機能一覧", SheetType.FUNCTION, [_row("F009", "账户变更", "変更", "AccountController")])]
|
||||
report = ImpactAgent().run(_ss(_existing(), tables))
|
||||
assert report.change_analysis.modified_elements[0].status == "warning"
|
||||
assert any("AccountController" in w.issue for w in report.change_analysis.warnings)
|
||||
|
||||
|
||||
def test_unchanged_count_in_summary():
|
||||
tables = [_table("機能一覧", SheetType.FUNCTION, [_row("F002", "订单查询", "変更", "OrderController")])]
|
||||
report = ImpactAgent().run(_ss(_existing(), tables))
|
||||
assert report.summary["unchanged"] >= 2 # 既有 5 类中未被引用者
|
||||
|
||||
|
||||
def test_table_without_change_column_skipped():
|
||||
tbl = ExcelTable(name="帳票一覧", detected_type=SheetType.REPORT, extraction_method="openpyxl",
|
||||
headers=["帳票ID", "帳票名"], rows=[])
|
||||
report = ImpactAgent().run(_ss(_existing(), [tbl]))
|
||||
assert report.change_analysis.new_elements == []
|
||||
assert report.change_analysis.warnings == []
|
||||
|
||||
|
||||
def test_run_without_existing_raises():
|
||||
ss = StructuredSource(tables=[], template=None, rule_docs=[], image_analyses=[],
|
||||
existing_system=None, comments=[])
|
||||
with pytest.raises(ValueError, match="既有系统"):
|
||||
ImpactAgent().run(ss)
|
||||
|
||||
|
||||
def test_metadata_and_summary_shape():
|
||||
tables = [_table("機能一覧", SheetType.FUNCTION, [_row("F001", "新功能", "新規", "")])]
|
||||
report = ImpactAgent().run(_ss(_existing(), tables), session_id="sess-1")
|
||||
assert report.metadata["version"] == "v1"
|
||||
assert report.metadata["session_id"] == "sess-1"
|
||||
assert report.metadata["source"] == "samples/existing-system"
|
||||
assert report.summary["total"] == 1
|
||||
assert report.summary["new"] == 1
|
||||
|
||||
|
||||
# ---------- 覆盖率补全(分支覆盖) ----------
|
||||
|
||||
def test_scope_logs_warning(caplog):
|
||||
tables = [_table("機能一覧", SheetType.FUNCTION, [_row("F001", "新功能", "新規", "")])]
|
||||
with caplog.at_level("WARNING", logger="genesis.impact.impact_agent"):
|
||||
ImpactAgent().run(_ss(_existing(), tables), scope={"modules": ["trade-order"]})
|
||||
assert any("scope" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_empty_and_unknown_change_values_skipped():
|
||||
def cv(v):
|
||||
return CellValue(v, Provenance("f.xlsx", "S", 1, "A", "h"))
|
||||
|
||||
headers = ["要素ID", "要素名", "変更区分", "既存対応"]
|
||||
rows = [
|
||||
{"要素ID": cv("F100"), "要素名": cv("空区分"), "変更区分": cv(""), "既存対応": cv("")},
|
||||
{"要素ID": cv("F101"), "要素名": cv("未知区分"), "変更区分": cv("未知"), "既存対応": cv("")},
|
||||
{"要素ID": cv("F102"), "要素名": cv("正常"), "変更区分": cv("新規"), "既存対応": cv("")},
|
||||
]
|
||||
tbl = ExcelTable("機能一覧", SheetType.FUNCTION, "openpyxl", headers, rows)
|
||||
report = ImpactAgent().run(_ss(_existing(), [tbl]))
|
||||
assert [e.element_id for e in report.change_analysis.new_elements] == ["F102"]
|
||||
assert report.change_analysis.warnings == []
|
||||
|
||||
|
||||
def test_deleted_without_mapping_warns_and_lists():
|
||||
tables = [_table("機能一覧", SheetType.FUNCTION, [_row("F030", "旧功能", "削除", "")])]
|
||||
report = ImpactAgent().run(_ss(_existing(), tables))
|
||||
assert report.change_analysis.deleted_elements[0].status == "warning"
|
||||
assert any(w.element_id == "F030" for w in report.change_analysis.warnings)
|
||||
|
||||
|
||||
def test_entity_without_table_name_matches_by_class():
|
||||
existing = _existing()
|
||||
existing.entity_layer.append(EntityInfo("NoTable", "NoTable", "NT.java", None, [], "NT.java"))
|
||||
tables = [_table("DB定義", SheetType.DATABASE, [_row("T100", "无表实体", "変更", "NoTable")])]
|
||||
report = ImpactAgent().run(_ss(existing, tables))
|
||||
assert report.change_analysis.modified_elements[0].impacted_existing == ["NoTable"]
|
||||
|
||||
|
||||
def test_matching_dedup_same_class_via_class_and_table():
|
||||
tables = [_table("DB定義", SheetType.DATABASE, [
|
||||
_row("T101", "订单表", "変更", "OrderDO,trade_order"),
|
||||
_row("T102", "订单综合", "変更", "Order,Order"),
|
||||
])]
|
||||
ca = ImpactAgent().run(_ss(_existing(), tables)).change_analysis
|
||||
by_id = {e.element_id: e for e in ca.modified_elements}
|
||||
assert by_id["T101"].impacted_existing == ["OrderDO"] # 类名+表名 → 同一实体去重
|
||||
assert by_id["T102"].impacted_existing.count("OrderController") == 1 # 前缀重复去重
|
||||
|
||||
|
||||
def test_cell_out_of_range_returns_empty():
|
||||
assert ImpactAgent._cell({}, ["A"], 5) == ""
|
||||
|
||||
|
||||
def test_impact_report_to_dict_serializable():
|
||||
from genesis.impact.impact_agent import impact_report_to_dict
|
||||
tables = [_table("機能一覧", SheetType.FUNCTION, [
|
||||
_row("F001", "止损风控", "新規", ""),
|
||||
_row("F002", "订单查询", "変更", "OrderController"),
|
||||
])]
|
||||
report = ImpactAgent().run(_ss(_existing(), tables), session_id="s")
|
||||
d = impact_report_to_dict(report)
|
||||
assert d["metadata"]["session_id"] == "s"
|
||||
assert d["change_analysis"]["new_elements"][0]["change_type"] == "新規"
|
||||
assert d["change_analysis"]["modified_elements"][0]["impacted_existing"] == ["OrderController"]
|
||||
assert d["summary"]["modified"] == 1
|
||||
@@ -28,3 +28,20 @@ def test_build_contexts_maps_chapters():
|
||||
assert c.design_rules == []
|
||||
assert c.template_styles == {"Heading1", "Normal"}
|
||||
assert c.template_marker.section_placeholder == "section:introduction"
|
||||
|
||||
|
||||
def test_build_contexts_without_impact_report_is_none():
|
||||
ctxs = build_contexts(_ss(), samples_dir="nonexistent_dir_xyz")
|
||||
assert ctxs[0].impact_report is None
|
||||
|
||||
|
||||
def test_build_contexts_carries_impact_report():
|
||||
from genesis.data_models import ChangeAnalysis, ChangeElement, ChangeType, ImpactReport
|
||||
ca = ChangeAnalysis(project_type="enhancement", new_elements=[], modified_elements=[],
|
||||
deleted_elements=[], unchanged_elements=[], warnings=[])
|
||||
report = ImpactReport(metadata={"version": "v1"}, change_analysis=ca, summary={})
|
||||
ss = _ss()
|
||||
ss.impact_report = report
|
||||
ctxs = build_contexts(ss, samples_dir="nonexistent_dir_xyz")
|
||||
assert ctxs[0].impact_report is report
|
||||
assert "project_type" in ctxs[0].to_vars()["impact"] or ctxs[0].to_vars()["impact"] != ""
|
||||
|
||||
@@ -14,14 +14,69 @@ def test_chapter_content_holds_blocks():
|
||||
assert c.version == 1
|
||||
|
||||
|
||||
def test_generation_context_no_impact_field():
|
||||
def test_generation_context_impact_field_default_none():
|
||||
ctx = GenerationContext(
|
||||
chapter_id="db_design", title="DB 设计",
|
||||
template_marker=ChapterSpec(chapter_id="db_design", title="DB 设计", section_placeholder="{{section:db_design}}"),
|
||||
structured_source=None, write_rules=["规则1"], design_rules=["规则2"],
|
||||
template_styles={"Heading 1"},
|
||||
)
|
||||
assert not hasattr(ctx, "impact")
|
||||
assert ctx.impact_report is None
|
||||
# to_vars 始终提供 impact 变量;无影响调查书时为空串
|
||||
assert ctx.to_vars()["impact"] == ""
|
||||
|
||||
|
||||
def test_generation_context_impact_var_formats_report():
|
||||
from genesis.data_models import ChangeAnalysis, ChangeElement, ChangeType, ImpactReport
|
||||
ca = ChangeAnalysis(
|
||||
project_type="enhancement",
|
||||
new_elements=[ChangeElement("F001", "機能", "止损风控", ChangeType.NEW)],
|
||||
modified_elements=[],
|
||||
deleted_elements=[],
|
||||
unchanged_elements=[],
|
||||
warnings=[],
|
||||
)
|
||||
report = ImpactReport(metadata={"version": "v1"}, change_analysis=ca, summary={"new": 1})
|
||||
ctx = GenerationContext(
|
||||
chapter_id="db_design", title="DB 设计",
|
||||
template_marker=ChapterSpec(chapter_id="db_design", title="DB 设计", section_placeholder="{{section:db_design}}"),
|
||||
structured_source=None, write_rules=["规则1"], design_rules=["规则2"],
|
||||
template_styles={"Heading 1"}, impact_report=report,
|
||||
)
|
||||
impact = ctx.to_vars()["impact"]
|
||||
assert "止损风控" in impact
|
||||
assert "F001" in impact
|
||||
|
||||
|
||||
def _impact_ctx(report):
|
||||
return GenerationContext(
|
||||
chapter_id="db_design", title="DB 设计",
|
||||
template_marker=ChapterSpec(chapter_id="db_design", title="DB 设计", section_placeholder="{{section:db_design}}"),
|
||||
structured_source=None, write_rules=[], design_rules=[],
|
||||
template_styles=set(), impact_report=report,
|
||||
)
|
||||
|
||||
|
||||
def test_impact_var_empty_when_report_without_analysis():
|
||||
from genesis.data_models import ImpactReport
|
||||
report = ImpactReport(metadata={"version": "v1"}, change_analysis=None, summary={})
|
||||
assert _impact_ctx(report).to_vars()["impact"] == ""
|
||||
|
||||
|
||||
def test_impact_var_formats_deleted_and_warnings():
|
||||
from genesis.data_models import ChangeAnalysis, ChangeElement, ChangeType, ImpactReport, ImpactWarning
|
||||
ca = ChangeAnalysis(
|
||||
project_type="enhancement",
|
||||
new_elements=[],
|
||||
modified_elements=[],
|
||||
deleted_elements=[ChangeElement("F030", "機能", "旧功能", ChangeType.DELETED)],
|
||||
unchanged_elements=[],
|
||||
warnings=[ImpactWarning("F030", "缺少既存対応")],
|
||||
)
|
||||
report = ImpactReport(metadata={}, change_analysis=ca, summary={"deleted": 1, "warnings": 1})
|
||||
impact = _impact_ctx(report).to_vars()["impact"]
|
||||
assert "[削除]" in impact and "旧功能" in impact
|
||||
assert "[警告]" in impact and "缺少既存対応" in impact
|
||||
|
||||
|
||||
def test_chapter_spec_placeholder_optional():
|
||||
|
||||
@@ -60,6 +60,12 @@ def test_prompt_template_enforces_title_language():
|
||||
assert "{{title}}" in WRITER_PROMPT_TEMPLATE
|
||||
|
||||
|
||||
def test_prompt_template_has_impact_context_var():
|
||||
# 影响调查结果作为生成主上下文:模板必须包含 impact 变量(无影响书时渲染为空串)
|
||||
assert "影响调查上下文" in WRITER_PROMPT_TEMPLATE
|
||||
assert "{{impact}}" in WRITER_PROMPT_TEMPLATE
|
||||
|
||||
|
||||
def test_generate_chapter_retries():
|
||||
class Boom(FakeEngine):
|
||||
def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2):
|
||||
|
||||
@@ -2,13 +2,20 @@ import pytest
|
||||
from types import SimpleNamespace
|
||||
from docx import Document
|
||||
|
||||
from genesis.data_models import ParsedTemplate, ChapterMarker
|
||||
from genesis.data_models import (
|
||||
ControllerInfo, EntityInfo, ExistingSystemInfo, ImpactReport, ParsedTemplate,
|
||||
ChapterMarker, ServiceInfo,
|
||||
)
|
||||
from genesis.writer.orchestrator import WriteOrchestrator
|
||||
from genesis.writer.models import ChapterContent
|
||||
|
||||
|
||||
class FakeEngine:
|
||||
def __init__(self):
|
||||
self.last_variables = None
|
||||
|
||||
def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2):
|
||||
self.last_variables = variables
|
||||
# 返回一个固定章节内容(title + 一个段落块)
|
||||
return SimpleNamespace(
|
||||
data={"title": variables["title"], "blocks": [{"type": "paragraph", "text": "自动生成的内容"}]},
|
||||
@@ -108,3 +115,90 @@ def test_generate_warns_on_unanchored_heading(tmp_path, caplog):
|
||||
template_path=str(tpl),
|
||||
)
|
||||
assert any("附録" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
# ---------- Impact Agent MVP:影响调查结果作为生成主上下文(2026-08-23) ----------
|
||||
|
||||
def _ss_with_existing(template_path):
|
||||
from genesis.data_models import CellValue, ExcelTable, Provenance, SheetType
|
||||
parsed = ParsedTemplate(
|
||||
file_name=template_path,
|
||||
sections=[
|
||||
ChapterMarker(type="heading", name="はじめに", level=1),
|
||||
ChapterMarker(type="placeholder", name="section:introduction", level=0),
|
||||
],
|
||||
placeholders={},
|
||||
styles={"defined": ["Heading 1"], "used": ["Heading 1"]},
|
||||
)
|
||||
existing = ExistingSystemInfo(
|
||||
controller_layer=[ControllerInfo("OrderController", "OrderController", "OrderController.java",
|
||||
"/trade/order", [], "trade-order/OrderController.java")],
|
||||
service_layer=[],
|
||||
entity_layer=[EntityInfo("OrderDO", "OrderDO", "OrderDO.java", "trade_order", ["id"], "trade-order/OrderDO.java")],
|
||||
api_endpoints=[],
|
||||
source_path="samples/existing-system",
|
||||
)
|
||||
headers = ["機能ID", "機能名", "変更区分", "既存対応"]
|
||||
rows = [{
|
||||
"機能ID": CellValue("F002", Provenance("f.xlsx", "機能一覧", 3, "A", "機能ID")),
|
||||
"機能名": CellValue("订单状态查询扩展", Provenance("f.xlsx", "機能一覧", 3, "B", "機能名")),
|
||||
"変更区分": CellValue("変更", Provenance("f.xlsx", "機能一覧", 3, "G", "変更区分")),
|
||||
"既存対応": CellValue("OrderController", Provenance("f.xlsx", "機能一覧", 3, "H", "既存対応")),
|
||||
}]
|
||||
tables = [ExcelTable(name="機能一覧", detected_type=SheetType.FUNCTION, extraction_method="openpyxl",
|
||||
headers=headers, rows=rows)]
|
||||
return SimpleNamespace(template=parsed, existing_system=existing, tables=tables,
|
||||
impact_report=None)
|
||||
|
||||
|
||||
def test_generate_auto_runs_impact_when_existing_system(tmp_path):
|
||||
"""门控:existing_system 非 None 且未显式传 impact_report → 自动跑影响调查,注入生成上下文。"""
|
||||
tpl = tmp_path / "tpl.docx"
|
||||
out = tmp_path / "out.docx"
|
||||
_make_template(str(tpl))
|
||||
engine = FakeEngine()
|
||||
WriteOrchestrator().generate(
|
||||
_ss_with_existing(str(tpl)), str(out),
|
||||
samples_dir="nonexistent_dir_xyz", engine=engine,
|
||||
)
|
||||
impact = engine.last_variables["impact"]
|
||||
assert "project_type=enhancement" in impact
|
||||
assert "OrderController" in impact
|
||||
|
||||
|
||||
def test_generate_without_existing_system_impact_empty(tmp_path):
|
||||
tpl = tmp_path / "tpl.docx"
|
||||
out = tmp_path / "out.docx"
|
||||
_make_template(str(tpl))
|
||||
engine = FakeEngine()
|
||||
WriteOrchestrator().generate(
|
||||
_ss(str(tpl)), str(out),
|
||||
samples_dir="nonexistent_dir_xyz", engine=engine,
|
||||
)
|
||||
assert engine.last_variables["impact"] == ""
|
||||
|
||||
|
||||
def test_generate_explicit_impact_report_used(tmp_path):
|
||||
from genesis.data_models import ChangeAnalysis, ChangeElement, ChangeType
|
||||
tpl = tmp_path / "tpl.docx"
|
||||
out = tmp_path / "out.docx"
|
||||
_make_template(str(tpl))
|
||||
ca = ChangeAnalysis(project_type="enhancement", new_elements=[], modified_elements=[],
|
||||
deleted_elements=[], unchanged_elements=[], warnings=[])
|
||||
report = ImpactReport(metadata={"version": "v1"}, change_analysis=ca, summary={"new": 0})
|
||||
engine = FakeEngine()
|
||||
ss = _ss_with_existing(str(tpl))
|
||||
WriteOrchestrator().generate(ss, str(out), samples_dir="nonexistent_dir_xyz",
|
||||
engine=engine, impact_report=report)
|
||||
assert engine.last_variables["impact"] != ""
|
||||
# 显式传入时也回填 structured_source,便于 QA/日志读取
|
||||
assert ss.impact_report is report
|
||||
|
||||
|
||||
def test_generate_missing_template_path_raises(tmp_path):
|
||||
parsed = ParsedTemplate(file_name=None, sections=[], placeholders={}, styles={"used": []})
|
||||
with pytest.raises(ValueError, match="template_path"):
|
||||
WriteOrchestrator().generate(
|
||||
SimpleNamespace(template=parsed), str(tmp_path / "out.docx"),
|
||||
samples_dir="nonexistent_dir_xyz", engine=FakeEngine(),
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from genesis.data_models import StructuredSource
|
||||
from genesis.impact.code_parser import CodeParseError
|
||||
from genesis.parsers.source_aggregator import SourceParser
|
||||
|
||||
from tests.docx_helpers import make_rule_doc, new_document, save_document
|
||||
@@ -125,3 +126,76 @@ def test_validate_path_returns_resolved_path(tmp_path):
|
||||
result = _validate_path(str(good), (".xlsx",))
|
||||
assert isinstance(result, Path)
|
||||
assert result.suffix.lower() == ".xlsx"
|
||||
|
||||
|
||||
# ---------- Impact Agent MVP:existing_system_path(2026-08-23) ----------
|
||||
|
||||
JAVA_PROJECT = {
|
||||
"trade-order/OrderController.java": (
|
||||
"package com.trade.order.controller;\n"
|
||||
"import org.springframework.web.bind.annotation.*;\n"
|
||||
"@RestController\n"
|
||||
"@RequestMapping(\"/api/order\")\n"
|
||||
"public class OrderController {\n"
|
||||
" @GetMapping(\"/{id}\")\n"
|
||||
" public String get(Long id) { return \"ok\"; }\n"
|
||||
"}\n"
|
||||
),
|
||||
"trade-order/OrderServiceImpl.java": (
|
||||
"package com.trade.order.service;\n"
|
||||
"import org.springframework.stereotype.Service;\n"
|
||||
"@Service\n"
|
||||
"public class OrderServiceImpl {\n"
|
||||
" public void createOrder() {}\n"
|
||||
"}\n"
|
||||
),
|
||||
"trade-order/OrderDO.java": (
|
||||
"package com.trade.order.entity;\n"
|
||||
"import com.baomidou.mybatisplus.annotation.TableName;\n"
|
||||
"@TableName(\"trade_order\")\n"
|
||||
"public class OrderDO {\n"
|
||||
" private Long id;\n"
|
||||
"}\n"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _java_project(tmp_path) -> str:
|
||||
for rel, content in JAVA_PROJECT.items():
|
||||
p = tmp_path / "existing" / rel
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(content, encoding="utf-8")
|
||||
return str(tmp_path / "existing")
|
||||
|
||||
|
||||
def test_parse_with_existing_system_path(tmp_path):
|
||||
xlsx = _xlsx(tmp_path)
|
||||
existing = _java_project(tmp_path)
|
||||
|
||||
result = SourceParser().parse(requirement_paths=[xlsx], existing_system_path=existing)
|
||||
|
||||
assert result.existing_system is not None
|
||||
names = {c.class_name for c in result.existing_system.controller_layer}
|
||||
assert "OrderController" in names
|
||||
assert {s.class_name for s in result.existing_system.service_layer} == {"OrderServiceImpl"}
|
||||
assert {e.class_name for e in result.existing_system.entity_layer} == {"OrderDO"}
|
||||
|
||||
|
||||
def test_parse_without_existing_system_path_keeps_none(tmp_path):
|
||||
xlsx = _xlsx(tmp_path)
|
||||
result = SourceParser().parse(requirement_paths=[xlsx])
|
||||
assert result.existing_system is None
|
||||
|
||||
|
||||
def test_parse_existing_system_path_invalid_dir_raises(tmp_path):
|
||||
xlsx = _xlsx(tmp_path)
|
||||
with pytest.raises(CodeParseError):
|
||||
SourceParser().parse(requirement_paths=[xlsx], existing_system_path=tmp_path / "nope")
|
||||
|
||||
|
||||
def test_parse_existing_system_path_without_java_raises(tmp_path):
|
||||
xlsx = _xlsx(tmp_path)
|
||||
empty = tmp_path / "empty"
|
||||
empty.mkdir()
|
||||
with pytest.raises(CodeParseError):
|
||||
SourceParser().parse(requirement_paths=[xlsx], existing_system_path=empty)
|
||||
|
||||
Reference in New Issue
Block a user