fix: 预览按 draft-{id} 命名且重建全量同构 + C1 空台账改判数据待补 + 立项经费结余结论 + CLI 拆分 import-*/list-* + docx 占位黄底

This commit is contained in:
chenfuqiang
2026-09-27 21:47:56 +08:00
parent 7b24521b43
commit db11f22dc8
11 changed files with 508 additions and 107 deletions

View File

@@ -92,6 +92,21 @@ class TestC1Completion(DraftKernelTestBase):
result = self._kernel(expected=[{"type": "论文", "unit": "篇"}])
self.assertEqual(result["completion"][0]["verdict"], "数据待补")
def test_empty_ledger_is_pending_not_unmet(self) -> None:
"""成果台账整表为空 → 全部"数据待补",不下"未达标"结论(C1/W1)。"""
result = self._kernel(achievements=[])
for row in result["completion"]:
self.assertEqual(row["verdict"], "数据待补")
self.assertIn("尚未录入", row["detail"])
def test_present_ledger_missing_type_is_unmet_zero(self) -> None:
"""台账非空但某类型无记录 → 实际按 0 计,未达标(事实陈述)。"""
result = self._kernel(achievements=[ach for ach in _achievements() if ach["achievementType"] != "专利"])
by_type = {row["type"]: row for row in result["completion"]}
self.assertEqual(by_type["专利"]["verdict"], "未达标")
self.assertEqual(by_type["专利"]["actual"], 0)
self.assertIn("实际 0 项", by_type["专利"]["detail"])
class TestC3Expenses(DraftKernelTestBase):
def test_ratio_sums_to_one_and_sentences(self) -> None:
@@ -106,6 +121,27 @@ class TestC3Expenses(DraftKernelTestBase):
result = self._kernel(expenses=[])
self.assertIn("尚未录入", result["expense_paragraph"])
def test_balance_within_approved_budget(self) -> None:
# 台账支出 273000 元,立项经费 500000 元 → 结余 22.70 万元
result = self._kernel()
total = round(sum(row["amount"] for row in result["expense_rows"]), 2)
self.assertEqual(total, 273000.0)
with_approved = self._kernel_with_approved(500000)
self.assertIn("合计支出 27.30 万元", with_approved)
self.assertIn("结余 22.70 万元", with_approved)
self.assertIn("范围内", with_approved)
def test_overspend_flags_exceed(self) -> None:
paragraph = self._kernel_with_approved(200000)
self.assertIn("超出立项经费", paragraph)
self.assertIn("7.30 万元", paragraph)
def _kernel_with_approved(self, approved: float) -> str:
from service.draft_service import build_draft_sections
project = _project(approvedBudget=approved)
return build_draft_sections(project, _achievements(), _expenses())["expense_paragraph"]
class TestC4AndC5(DraftKernelTestBase):
def test_narrative_points_with_source(self) -> None:
@@ -166,15 +202,42 @@ class TestDraftGeneration(unittest.TestCase):
self.assertIn("经费决算说明", xml)
self.assertIn("来源:成果清单", xml)
self.assertIn("待补充", xml)
self.assertIn('w:highlight w:val="yellow"', xml) # [待补充] 占位黄底提示
def test_preview_and_status_update(self) -> None:
def test_preview_named_by_draft_id_and_status_update(self) -> None:
result = self._draft()
self.assertTrue(result["preview_file"].endswith(".html"))
self.assertTrue(result["preview_file"].endswith("draft-1.html"))
from service.ledger_service import list_projects
row = next(p for p in list_projects() if p["project_no"] == "NSFC2026003")
self.assertEqual(row["status"], "初稿已生成")
def test_second_draft_preview_not_overwritten(self) -> None:
from service.ledger_service import upsert_project
from service.draft_service import query_pending_projects, run_draft
upsert_project(_project(projectNo="NSFC2026004", projectName="第二个项目"))
result2 = run_draft(query_pending_projects(project_no="NSFC2026004")[0], [], [])
result1 = run_draft(query_pending_projects(project_no="NSFC2026003")[0], [], [])
self.assertIn("draft-1.html", result2["preview_file"])
self.assertIn("draft-2.html", result1["preview_file"])
self.assertNotEqual(result1["preview_file"], result2["preview_file"])
def test_rebuild_preview_is_full_sections(self) -> None:
from service.report_service import build_preview_for_draft_id
result = self._draft()
draft_id = result["draft_id"]
path = build_preview_for_draft_id(draft_id)
self.assertIn(f"draft-{draft_id}.html", path)
with open(path, encoding="utf-8") as f:
html_text = f.read()
# 与 Word 初稿同构:决算、成果清单、占位指引齐全(而非降级的三节摘要)
self.assertIn("经费决算说明", html_text)
self.assertIn("成果清单", html_text)
self.assertIn("研究背景与目标", html_text)
self.assertIn("待补充", html_text)
def test_batch_pending(self) -> None:
from service.draft_service import query_pending_projects, run_draft
@@ -192,5 +255,42 @@ class TestDraftGeneration(unittest.TestCase):
self.assertEqual(query_pending_projects(project_no="NOPE0001"), [])
class TestCliContract(unittest.TestCase):
"""CLI 契约:文档 §8 的六个台账命令可解析,render-html 占位符容错。"""
def _parser(self):
from cli.app import build_parser
return build_parser()
def test_contract_commands_parse(self) -> None:
parser = self._parser()
cases = [
["import-projects", "--projects-json", "x.json"],
["import-achievements", "--achievements-json", "-"],
["import-expenses", "--expenses-json", "x.json"],
["list-projects", "--status", "待结题"],
["list-achievements", "--project-no", "NSFC2026003"],
["list-expenses", "--project-no", "NSFC2026003"],
["add-project", "--project-no", "X", "--approved-budget", "500000"],
]
for argv in cases:
args = parser.parse_args(argv)
self.assertTrue(callable(getattr(args, "handler", None)), argv)
def test_legacy_import_list_aliases_kept(self) -> None:
parser = self._parser()
args = parser.parse_args(["import", "--projects-json", "x.json"])
self.assertEqual(args.projects_json, "x.json")
args = parser.parse_args(["list", "--kind", "expenses", "--project-no", "X"])
self.assertEqual(args.kind, "expenses")
def test_render_html_unresolved_placeholder_falls_back(self) -> None:
parser = self._parser()
args = parser.parse_args(["render-html", "--report-id", "{{id}}"])
# 未解析占位符不应让渲染崩溃:handler 内部按缺省(最近 10 份)处理
self.assertEqual(args.report_id, "{{id}}")
if __name__ == "__main__":
unittest.main()