Files
draft-closing-report-default/tests/test_draft_rules.py

297 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""结题初稿内核金标准用例(离线、确定性;docx 用 zipfile 断言内容)。"""
from __future__ import annotations
import json
import unittest
import zipfile
from _support import IsolatedDataRoot
def _project(**overrides) -> dict:
base = {
"projectNo": "NSFC2026003",
"projectName": "面向能源转化的材料设计",
"piName": "王教授",
"department": "化材学院",
"program": "国家自然科学基金青年项目",
"startDate": "2024-01-01",
"endDate": "2026-12-31",
"expected": [
{"type": "论文", "target": 3, "unit": "篇"},
{"type": "专利", "target": 1, "unit": "项"},
{"type": "人才培养", "target": 2, "unit": "名"},
],
"annualReportExcerpt": "完成界面改性工艺探索;发表高水平论文两篇。",
"status": "待结题",
}
base.update(overrides)
return base
def _achievements() -> list[dict]:
return [
{"projectNo": "NSFC2026003", "achievementType": "论文", "title": "界面调控研究A", "venueOrNo": "Applied Energy", "achDate": "2025-06-01", "isRepresentative": "1"},
{"projectNo": "NSFC2026003", "achievementType": "论文", "title": "界面调控研究B", "venueOrNo": "J. Mater. Chem. A", "achDate": "2025-11-01", "isRepresentative": "1"},
{"projectNo": "NSFC2026003", "achievementType": "论文", "title": "界面调控研究C", "venueOrNo": "", "achDate": "2026-03-01", "isRepresentative": "0"},
{"projectNo": "NSFC2026003", "achievementType": "论文", "title": "界面调控研究D", "venueOrNo": "", "achDate": "2026-05-01", "isRepresentative": "0"},
{"projectNo": "NSFC2026003", "achievementType": "专利", "title": "一种界面改性方法", "venueOrNo": "ZL2025XXXX", "achDate": "2026-01-01", "isRepresentative": "0"},
{"projectNo": "NSFC2026003", "achievementType": "人才培养", "title": "硕士研究生1名毕业", "venueOrNo": "", "achDate": "2026-06-30", "isRepresentative": "0"},
]
def _expenses() -> list[dict]:
return [
{"projectNo": "NSFC2026003", "category": "差旅费", "amount": 23000.0, "occurredDate": "2025-04-10", "description": "赴外地调研"},
{"projectNo": "NSFC2026003", "category": "差旅费", "amount": 10000.0, "occurredDate": "2025-09-01", "description": "学术会议"},
{"projectNo": "NSFC2026003", "category": "设备费", "amount": 200000.0, "occurredDate": "2024-12-01", "description": "测试设备"},
{"projectNo": "NSFC2026003", "category": "劳务费", "amount": 40000.0, "occurredDate": "2026-06-01", "description": "研究生劳务"},
]
class DraftKernelTestBase(unittest.TestCase):
def setUp(self) -> None:
self._root = IsolatedDataRoot()
self._root.__enter__()
from db.connection import init_db
init_db()
def tearDown(self) -> None:
self._root.__exit__(None, None, None)
def _kernel(self, expected=None, achievements=None, expenses=None):
from service.draft_service import build_draft_sections
project = _project()
if expected is not None:
project["expected_json"] = json.dumps(expected, ensure_ascii=False)
return build_draft_sections(project, achievements if achievements is not None else _achievements(), expenses if expenses is not None else _expenses())
class TestC1Completion(DraftKernelTestBase):
def test_over_and_equal(self) -> None:
result = self._kernel()
by_type = {row["type"]: row for row in result["completion"]}
self.assertEqual(by_type["论文"]["verdict"], "超额完成") # 4 > 3
self.assertEqual(by_type["专利"]["verdict"], "持平") # 1 == 1
def test_below_target(self) -> None:
result = self._kernel()
by_type = {row["type"]: row for row in result["completion"]}
self.assertEqual(by_type["人才培养"]["verdict"], "未达标") # 1 < 2
self.assertIn("预期 2 名,实际 1 名", by_type["人才培养"]["detail"])
def test_missing_type_is_not_met_with_zero(self) -> None:
result = self._kernel()
by_type = {row["type"]: row for row in result["completion"]}
self.assertEqual(by_type["专利"]["actual"], 1)
def test_missing_target_is_pending(self) -> None:
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:
result = self._kernel()
total_ratio = round(sum(row["ratio"] for row in result["expense_rows"]), 4)
self.assertEqual(total_ratio, 1.0)
top = result["expense_rows"][0]
self.assertEqual(top["category"], "设备费") # 金额最大排前
self.assertIn("购置/使用科研设备", top["sentence"])
def test_empty_expenses_placeholder(self) -> None:
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:
result = self._kernel()
sources = {p["source"] for p in result["narrative_points"]}
self.assertIn("成果清单", sources)
self.assertIn("年度报告", sources)
def test_sections_order_and_placeholders(self) -> None:
result = self._kernel()
titles = list(result["sections"].keys())
self.assertEqual(titles[:3], ["1. 研究背景与目标", "2. 研究内容与进展", "3. 预期成果完成情况"])
self.assertIn("来源:成果清单", "\n".join(result["sections"]["5. 成果清单"]))
self.assertTrue(any(p.startswith("[待补充") for p in result["sections"]["1. 研究背景与目标"]))
def test_w1_empty_ledger_does_not_block(self) -> None:
result = self._kernel(achievements=[], expenses=[])
self.assertIn("尚未录入", "\n".join(result["sections"]["5. 成果清单"]))
self.assertIn("尚未录入", result["expense_paragraph"])
class TestDraftGeneration(unittest.TestCase):
def setUp(self) -> None:
self._root = IsolatedDataRoot()
self._root.__enter__()
from db.connection import init_db
from service.ledger_service import import_expenses, import_achievements, import_projects, upsert_project
init_db()
upsert_project(_project())
for ach in _achievements():
from service.ledger_service import upsert_achievement
upsert_achievement(ach)
for expense in _expenses():
from service.ledger_service import upsert_expense
upsert_expense(expense)
def tearDown(self) -> None:
self._root.__exit__(None, None, None)
def _draft(self, project_no="NSFC2026003"):
from service.draft_service import query_pending_projects, run_draft
from service.ledger_service import list_achievements, list_expenses
projects = query_pending_projects(project_no=project_no)
assert projects, "project should exist"
return run_draft(projects[0], list_achievements(project_no), list_expenses(project_no))
def test_docx_generated_with_sections(self) -> None:
result = self._draft()
self.assertTrue(result["draft_file"].endswith(".docx"))
with zipfile.ZipFile(result["draft_file"]) as zf:
xml = zf.read("word/document.xml").decode("utf-8")
self.assertIn("结题报告(初稿)", xml)
self.assertIn("研究背景与目标", xml)
self.assertIn("经费决算说明", xml)
self.assertIn("来源:成果清单", xml)
self.assertIn("待补充", xml)
self.assertIn('w:highlight w:val="yellow"', xml) # [待补充] 占位黄底提示
def test_preview_named_by_draft_id_and_status_update(self) -> None:
result = self._draft()
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
for project in query_pending_projects():
run_draft(project, [], [])
from service.ledger_service import list_drafts, stats
payload = stats()
self.assertEqual(payload["draft_total"], 1)
self.assertEqual(len(list_drafts()), 1)
def test_project_not_found(self) -> None:
from service.draft_service import query_pending_projects
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()