feat: 结题报告初稿助手首版 v1.0.1(draft-closing-report-default)
All checks were successful
技能自动化发布 / release (push) Successful in 6s
All checks were successful
技能自动化发布 / release (push) Successful in 6s
This commit is contained in:
196
tests/test_draft_rules.py
Normal file
196
tests/test_draft_rules.py
Normal file
@@ -0,0 +1,196 @@
|
||||
# -*- 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"], "数据待补")
|
||||
|
||||
|
||||
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"])
|
||||
|
||||
|
||||
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)
|
||||
|
||||
def test_preview_and_status_update(self) -> None:
|
||||
result = self._draft()
|
||||
self.assertTrue(result["preview_file"].endswith(".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_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"), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user