# -*- coding: utf-8 -*- """tests/docgen/test_unit_acceptance.py —— docgen P0 验收口径用例(测试 agent 归属)。 对应 test_case_report.md: DC-UNIT-CRYPTO-01~04 / DC-UNIT-VAULT-01~10 / DC-UNIT-RETRIEVAL-01~03 DC-UNIT-BACKEND-01~03 / DC-UNIT-INTENT-01~05 / DC-UNIT-CONVERT-01~06 DC-UNIT-PIPELINE-01~08 / DC-UNIT-EDITOR-01~03 / DC-UNIT-CLIENT-01~05 口径(黑盒验收,与开发自测互补): - 公开函数/端点/文件系统断言,不读实现内部; - 全部用例只依赖 mock/fixture/tmpdir,不依赖 ollama/LLM/网络/生产服务; - 开发模块未交付时:pytest 收集期跳过(PENDING/SKIP),不视为 FAIL。 v1.2 签名适配(2026-09-03,TL 权威签名): - Vault(vault_path, index_path) 两参构造;set_embed_fn/get_embed_fn 注入假嵌入 - add 返回含 document_id 的 dict;get_decrypted 返回 dict|None(非 bytes) - parse_backend(vault_spec, index_spec) 两参;handshake(backend) 独立函数 - get_fernet_key() 调用即抛(VSTORE_KEY 缺失) - 检索三档 mode:vector/keyword/latest/empty(关键词档名非 meta) - parsing:extract/extract_docx/extract_pdf/extract_text_file; outline_to_markdown/outline_to_embedding_chunks 入参为 outline(非 md 字符串) - 断言口径不变(验收预期),仅适配调用签名 执行:bash tests/docgen/run_all.sh unit """ from __future__ import annotations import json import sys from pathlib import Path import pytest REPO = Path(__file__).resolve().parents[2] DATA = Path(__file__).resolve().parent / "data" # vstore(仓库根)与 app(sdk/)双包可导入(cwd 不为仓库根时仍可跑) sys.path.insert(0, str(REPO)) sys.path.insert(0, str(REPO / "sdk")) pytestmark = pytest.mark.unit # --------------------------------------------------------------------------- # 通用夹具 # --------------------------------------------------------------------------- def _importable(mod: str) -> bool: try: __import__(mod) return True except ImportError: return False def _mod(qualname: str): """按完整模块名导入(getattr 链对未导入的子模块会误判缺失)。""" try: import importlib return importlib.import_module(qualname) except (ImportError, AttributeError): return None @pytest.fixture def skip_pending(request): """按用例声明的依赖模块决定跳过(开发未交付=PENDING)。""" marker = request.node.get_closest_marker("needs") if marker is not None: missing = [m for m in marker.args[0] if not _importable(m)] if missing: pytest.skip("PENDING: 开发模块未交付 " + ",".join(missing)) yield # --------------------------------------------------------------------------- # 假嵌入(dev1 权威注入机制:vstore.embed.set_embed_fn / vault._EMBED_FN) # --------------------------------------------------------------------------- def fake_embed(texts): """假嵌入:文本首字符哈希 → 单位基向量方向,确定且可区分(1024 维)。""" import numpy as np dim = 1024 out = [] for text in texts: if not text or not text.strip(): out.append([0.0] * dim) continue seed = ord(text[0]) vec = np.zeros(dim, dtype=np.float32) vec[seed % dim] = 1.0 if seed % 7 == 0: vec[(seed + 1) % dim] = 0.5 out.append((vec / float(np.linalg.norm(vec))).tolist()) return out def fake_embed_fail(texts): """假嵌入失败(模拟 ollama 宕机 → 检索降级档)。""" raise RuntimeError("simulated embed down") def _install_fake_embed(monkeypatch, fn=None): """向 vstore.embed 与 vstore.vault 注入假嵌入(两处都可注入,兼容实现)。""" fn = fn or fake_embed emod = _mod("vstore.embed") vmod = _mod("vstore.vault") if emod is not None: try: monkeypatch.setattr(emod, "_embed_fn", fn) monkeypatch.setattr(emod, "set_embed_fn", fn) # 防实现内部再 set 覆盖 except Exception: pass if hasattr(emod, "set_embed_fn"): emod.set_embed_fn(fn) if vmod is not None: try: monkeypatch.setattr(vmod, "_EMBED_FN", fn) except Exception: pass @pytest.fixture def vstore_env(monkeypatch, tmp_path): """VSTORE 环境:固定 Fernet key + 路径注入 tmpdir(get_paths 缓存清理)。""" monkeypatch.setenv( "VSTORE_KEY", "3CW0cVSYhnDo-bwkrZL5ElLhNrCtl5zyuRZvATckirs=" ) monkeypatch.setenv("VSTORE_ROOT", str(tmp_path / "vstore")) monkeypatch.setenv("VSTORE_VAULT_PATH", str(tmp_path / "vstore" / "vault")) monkeypatch.setenv("VSTORE_INDEX_PATH", str(tmp_path / "vstore" / "index")) smod = _mod("vstore.settings") if smod is not None and hasattr(smod, "_paths"): monkeypatch.setattr(smod, "_paths", None) return tmp_path # --------------------------------------------------------------------------- # DC-UNIT-CRYPTO-01~04 # --------------------------------------------------------------------------- @pytest.mark.needs(["vstore.crypto"]) class TestCrypto: @pytest.fixture(autouse=True) def _skip(self, skip_pending, vstore_env): pass def _crypto(self): return _mod("vstore.crypto") def test_DC_UNIT_CRYPTO_01_roundtrip(self): crypto = self._crypto() plain = ("占位测试文本·保密断言样例" * 3).encode("utf-8") out = crypto.decrypt_bytes(crypto.encrypt_bytes(plain)) assert out == plain def test_DC_UNIT_CRYPTO_02_ciphertext_no_plaintext_window(self): crypto = self._crypto() plain = ("占位测试文本·保密断言样例" * 3).encode("utf-8") cipher = crypto.encrypt_bytes(plain) for i in range(0, len(plain) - 8): assert plain[i : i + 8] not in cipher def test_DC_UNIT_CRYPTO_03_wrong_key_raises(self): crypto = self._crypto() with pytest.raises(Exception): crypto.decrypt_bytes(b"not-a-valid-ciphertext") def test_DC_UNIT_CRYPTO_04_secure_delete(self, tmp_path): crypto = self._crypto() f = tmp_path / "t.bin" f.write_bytes(b"x" * 64) crypto.secure_delete(str(f)) assert not f.exists() # --------------------------------------------------------------------------- # 样例 docx / 空向量检索保证 # --------------------------------------------------------------------------- def _sample_docx(path: Path) -> bytes: try: import docx except ImportError: pytest.skip("python-docx 不可用") d = docx.Document() d.add_heading("占位测试标题", level=1) d.add_paragraph("这是保密断言独有标志串 DOCGEN_QA_SENTINEL_9F3A。") t = d.add_table(rows=1, cols=2) t.rows[0].cells[0].text = "列一" t.rows[0].cells[1].text = "列二" d.save(str(path)) return path.read_bytes() def _make_vault(tmp_path): """按 dev1 权威签名构造本地主库:Vault(vault_path, index_path) 两参。""" vmod = _mod("vstore.vault") if vmod is None: pytest.skip("PENDING: vstore.vault 未交付") va = tmp_path / "vault" ix = tmp_path / "index" va.mkdir(parents=True, exist_ok=True) ix.mkdir(parents=True, exist_ok=True) vault = vmod.Vault(str(va), str(ix)) return vault, va, ix # --------------------------------------------------------------------------- # DC-UNIT-VAULT-01~10 # --------------------------------------------------------------------------- @pytest.mark.needs(["vstore.vault", "vstore.crypto", "vstore.parsing"]) class TestVault: @pytest.fixture(autouse=True) def _skip(self, skip_pending, vstore_env, monkeypatch): _install_fake_embed(monkeypatch) def test_DC_UNIT_VAULT_01_add_returns_record(self, tmp_path): vault, va, ix = _make_vault(tmp_path) item = vault.add(_sample_docx(tmp_path / "s.docx"), "样例通知.docx", {"name": "样例通知", "category": "通知", "tags": "占位"}, True) assert item["document_id"] # dev1 权威签名:document_id assert item.get("name") assert item.get("as_template") is True assert item.get("enc_path") and Path(item["enc_path"]).exists() def test_DC_UNIT_VAULT_02_no_plaintext_on_disk(self, tmp_path): """保密断言(黄金):vault 目录内任何文件不含入库明文标志串。""" vault, va, ix = _make_vault(tmp_path) vault.add(_sample_docx(tmp_path / "s.docx"), "样例通知.docx", {"name": "样例通知", "category": "通知"}, False) for f in list(va.rglob("*")) + list(ix.rglob("*")): if f.is_file(): data = f.read_bytes() assert "DOCGEN_QA_SENTINEL_9F3A".encode("utf-8") not in data def test_DC_UNIT_VAULT_03_only_enc_and_index(self, tmp_path): """ls vault:只见 .enc;检索索引落 index 目录(index.json)。""" vault, va, ix = _make_vault(tmp_path) vault.add(_sample_docx(tmp_path / "s.docx"), "样例通知.docx", {"name": "样例通知", "category": "通知"}, False) vault_names = [f.name for f in va.rglob("*") if f.is_file()] assert vault_names, "vault 目录为空" for n in vault_names: assert n.endswith(".enc"), f"vault 目录出现非密文文件: {n}" index_names = [f.name for f in ix.rglob("*") if f.is_file()] assert "index.json" in index_names def test_DC_UNIT_VAULT_04_get_decrypted_and_unknown_id(self, tmp_path): vault, va, ix = _make_vault(tmp_path) item = vault.add(_sample_docx(tmp_path / "s.docx"), "样例通知.docx", {"name": "样例通知", "category": "通知"}, False) got = vault.get_decrypted(item["document_id"]) # dev1 权威签名:get_decrypted 返回 dict(meta + extracted),非 bytes assert isinstance(got, dict) assert got.get("meta") is not None assert got.get("extracted") is not None # 解密后的 extracted 结构含明文正文(仅内存);磁盘断言由 VAULT-02/03 覆盖 assert vault.get_decrypted("不存在-id") is None # 未知 id 返回 None(明确语义) def test_DC_UNIT_VAULT_05_delete_and_search(self, tmp_path): vault, va, ix = _make_vault(tmp_path) a = vault.add(_sample_docx(tmp_path / "a.docx"), "A.docx", {"name": "通知A", "category": "通知"}, False) b = vault.add(_sample_docx(tmp_path / "b.docx"), "B.docx", {"name": "报告B", "category": "报告"}, True) assert vault.delete(a["document_id"]) is True # dev1 签名:返回 bool assert vault.get_decrypted(a["document_id"]) is None assert (va / f"{a['document_id']}.enc").exists() is False hits = vault.search("小麦", top_k=5, category="通知") assert a["document_id"] not in [h.get("id") for h in hits.get("items", [])] def test_DC_UNIT_VAULT_06_docx_fields(self, tmp_path): pmod = _mod("vstore.parsing") docx_bytes = _sample_docx(tmp_path / "s.docx") out = pmod.extract_docx(docx_bytes) assert out.get("outline") is not None assert out.get("paragraphs") is not None assert out.get("tables") is not None assert out.get("style_hints") is not None def test_DC_UNIT_VAULT_06b_pdf_301_pages_accepted(self, tmp_path): # 2026-09-04 手工测试:PDF 不再设页数上限,301 页应正常解析(非拒绝) pmod = _mod("vstore.parsing") try: import pymupdf as fitz except ImportError: pytest.skip("pymupdf 不可用,无法构造 301 页样例") doc = fitz.open() for _ in range(301): doc.new_page() pdf_bytes = doc.tobytes() doc.close() parsed = pmod.extract_pdf(pdf_bytes) assert isinstance(parsed, dict) def test_DC_UNIT_VAULT_07_gb18030_fallback(self, tmp_path): pmod = _mod("vstore.parsing") raw = "粮食安全与育种,占位文本。".encode("gb18030") out = pmod.extract_text_file(raw) assert "粮食安全" in out["paragraphs"][0] def test_DC_UNIT_VAULT_08_outline_chunks_capped(self): pmod = _mod("vstore.parsing") # dev1 权威签名:outline_to_embedding_chunks 入参为 outline(dict 列表) outline = [ {"level": 1, "text": f"第{i}节 占位标题"} for i in range(1, 40) ] for item in outline: item["text"] += "(长文本" * 80 md = pmod.outline_to_markdown(outline) assert md and md.startswith("#") chunks = pmod.outline_to_embedding_chunks(outline) assert 1 <= len(chunks) <= 3 def test_DC_UNIT_VAULT_09_embed_concurrency(self, monkeypatch): emod = _mod("vstore.embed") import threading lock = threading.Lock() concurrent = 0 peak = 0 class FakeClient: def __init__(self): pass def post(self, url, json=None, timeout=None): nonlocal concurrent, peak with lock: concurrent += 1 peak = max(peak, concurrent) import time time.sleep(0.01) with lock: concurrent -= 1 import httpx return httpx.Response( 200, json={"embeddings": [[0.1] * 1024 for _ in range(1)]} ) def __enter__(self): return self def __exit__(self, *a): pass monkeypatch.setattr(emod.httpx, "Client", lambda: FakeClient()) results = emod.embed_batch(["文本%d" % i for i in range(10)]) assert len(results) == 10 assert peak <= 8 def test_DC_UNIT_VAULT_10_no_upload_limits(self, tmp_path): # 2026-09-04 手工测试:上传不再设大小/数量/字符门槛;嵌入预算常量替代 smod = _mod("vstore.settings") assert not hasattr(smod, "MAX_ATTACH_COUNT") assert not hasattr(smod, "MAX_ATTACH_BYTES") assert not hasattr(smod, "MAX_ATTACH_CHARS") assert smod.EMBED_BODY_CAP == 200_000 vault, va, ix = _make_vault(tmp_path) big = b"x" * (21 * 1024 * 1024) # 21MB(旧 20MB 门槛之上) item = vault.add(big, "big.txt", {"name": "big"}, False) assert item["document_id"] doc = vault.get_decrypted(item["document_id"]) assert doc["meta"]["size"] == len(big) # --------------------------------------------------------------------------- # DC-UNIT-RETRIEVAL-01~03(三档降级) # --------------------------------------------------------------------------- @pytest.mark.needs(["vstore.retrieval", "vstore.vault"]) class TestRetrieval: @pytest.fixture(autouse=True) def _skip(self, skip_pending, vstore_env, monkeypatch): _install_fake_embed(monkeypatch) def _search(self, tmp_path): """构造 5 条模版资料。样例 docx 的 title=哨兵段落(首字「这」)、 大纲块首字「#」;嵌入向量按首字哈希(假 embed 确定性)。""" vault, va, ix = _make_vault(tmp_path) for i in range(5): vault.add( _sample_docx(tmp_path / f"s{i}.docx"), f"通知样例{i}.docx", {"name": f"通知样例{i}", "category": "通知", "description": "关于小麦育种的年度通知第%d号" % i}, True, ) return vault def test_DC_UNIT_RETRIEVAL_01_vector_hit(self, tmp_path): # 查询向量与已存向量同向(首字「这」命中 title 块)→ 向量档 vault = self._search(tmp_path) res = vault.search("这是", top_k=3, category="通知", as_template=True) assert res["mode"] == "vector" assert 0 < len(res["items"]) <= 3 def test_DC_UNIT_RETRIEVAL_02_keyword_fallback(self, tmp_path): # 查询向量零命中(首字「小」与存量向量正交 → best_score≈0)→ 关键词档 # dev1 权威实现:mode="keyword"(非 "meta");降级由分数阈值驱动 vault = self._search(tmp_path) res = vault.search("小麦育种", top_k=3, category="通知", as_template=True) assert res["mode"] == "keyword" assert len(res["items"]) > 0 def test_DC_UNIT_RETRIEVAL_03_latest_fallback(self, tmp_path): # 向量零命中 + 关键词无命中 → 最新档(mode="latest",≤8 条) vault = self._search(tmp_path) res = vault.search("量子计算xyz", top_k=10, category="通知", as_template=True) assert res["mode"] == "latest" assert len(res["items"]) <= 8 # --------------------------------------------------------------------------- # DC-UNIT-BACKEND-01~03(路径三形态 + 握手拒绝) # --------------------------------------------------------------------------- @pytest.mark.needs(["vstore.backend"]) class TestBackend: @pytest.fixture(autouse=True) def _skip(self, skip_pending): pass def test_DC_UNIT_BACKEND_01_bare_path_local(self, tmp_path): bmod = _mod("vstore.backend") # dev1 权威签名:parse_backend(vault_spec, index_spec) 两参 be = bmod.parse_backend(str(tmp_path / "va"), str(tmp_path / "ix")) assert be.kind == "local" def test_DC_UNIT_BACKEND_02_file_url_local(self, tmp_path): bmod = _mod("vstore.backend") be = bmod.parse_backend( "file://" + str(tmp_path / "va"), "file://" + str(tmp_path / "ix"), ) assert be.kind == "local" # file:// 前缀剥掉 assert str(be.vault_path) == str(tmp_path / "va") def test_DC_UNIT_BACKEND_03_remote_handshake_reject(self): bmod = _mod("vstore.backend") # 不可达端口 → RemoteBackend;handshake 抛 BackendError(拒绝启动) be = bmod.parse_backend("http://127.0.0.1:1/va", "http://127.0.0.1:1/ix") assert be.kind == "remote" with pytest.raises(bmod.BackendError): bmod.handshake(be) # --------------------------------------------------------------------------- # DC-UNIT-INTENT-01~05 # --------------------------------------------------------------------------- @pytest.mark.needs(["app.services.docgen.intent"]) class TestIntent: @pytest.fixture(autouse=True) def _skip(self, skip_pending): pass def _route(self): imod = _mod("app.services.docgen.intent") fn = getattr(imod, "route", None) or getattr(imod, "route_intent", None) assert fn is not None, "intent.route 未找到" return fn def _vocab(self): imod = _mod("app.services.docgen.intent") words = {} for key in ("EXPORT_WORD", "FORMAT_WORD", "EDIT_WORD", "WRITE_VERB", "DOC_TYPE"): val = getattr(imod, key, None) if val is not None: words[key] = val # dev1 排除词实现为 DOC_TYPE_EXCLUDE(与冻结表 EXCLUDED_WORD 同义) excl = getattr(imod, "DOC_TYPE_EXCLUDE", None) or getattr(imod, "EXCLUDED_WORD", None) if excl is not None: words["EXCLUDED_WORD"] = excl return words def _frozen(self): return json.loads((DATA / "intent_vocab.json").read_text(encoding="utf-8")) def test_DC_UNIT_INTENT_01_vocab_matches_frozen(self): assert self._vocab(), "intent 模块未导出词典常量" frozen = self._frozen() for key, val in self._vocab().items(): assert sorted(val) == sorted(frozen[key]), f"词典 {key} 与冻结表不一致" def test_DC_UNIT_INTENT_02_export_priority(self): assert self._route()("帮我写个报告,顺便导出成PDF", False, True) == "export" def test_DC_UNIT_INTENT_03_edit_priority(self): assert self._route()("第2段改正式一点,顺便帮我写个总结", False, True) == "edit" def test_DC_UNIT_INTENT_04_export_without_active_artifact_is_qa(self): assert self._route()("导出成PDF", False, False) == "qa" def test_DC_UNIT_INTENT_05_unknown_is_qa(self): assert self._route()("今天天气怎么样", False, False) == "qa" # --------------------------------------------------------------------------- # DC-UNIT-CONVERT-01~06 # --------------------------------------------------------------------------- _SAMPLE_MD = """# 占位测试标题 正文段落,用于字体断言。这里是第二句。 - 列表项一 - 列表项二 1. 有序项一 2. 有序项二 | 列A | 列B | |---|---| | 甲 | 乙 | > 引用行 """ @pytest.mark.needs(["app.services.docgen.md2docx", "app.services.docgen.md2pdf"]) class TestConvert: @pytest.fixture(autouse=True) def _skip(self, skip_pending): pass def _docx(self): dmod = _mod("app.services.docgen.md2docx") fn = getattr(dmod, "docx_from_markdown", None) or getattr(dmod, "md_to_docx", None) assert fn is not None, "docx 转换函数未找到" return fn def _pdf(self): pmod = _mod("app.services.docgen.md2pdf") fn = getattr(pmod, "pdf_from_markdown", None) or getattr(pmod, "md_to_pdf", None) assert fn is not None, "pdf 转换函数未找到" return fn def test_DC_UNIT_CONVERT_01_docx_opens_and_fonts(self, tmp_path): out = tmp_path / "out.docx" self._docx()(_SAMPLE_MD, str(out)) import docx as docxlib doc = docxlib.Document(str(out)) assert len(doc.paragraphs) > 0 styles = {p.style.name for p in doc.paragraphs} assert "Heading 1" in styles body = [p for p in doc.paragraphs if p.style.name == "Normal" and p.text.strip()] if body: run = body[0].runs[0] rpr = run._element.rPr assert run.font.name == "宋体" east = rpr.find(".//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}rFonts") assert east is not None and east.get("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}eastAsia") == "宋体" def test_DC_UNIT_CONVERT_02_style_mapping(self, tmp_path): out = tmp_path / "out.docx" self._docx()(_SAMPLE_MD, str(out)) import docx as docxlib doc = docxlib.Document(str(out)) styles = {p.style.name for p in doc.paragraphs} for s in ("Heading 1", "List Bullet", "List Number", "Quote"): assert s in styles assert len(doc.tables) == 1 cell0 = doc.tables[0].rows[0].cells[0] text_run = next(r for r in cell0.paragraphs[0].runs if r.text.strip()) assert text_run.font.bold is True # 表头加粗(取含文本的 run) def test_DC_UNIT_CONVERT_03_pdf_opens(self, tmp_path): out = tmp_path / "out.pdf" self._pdf()(_SAMPLE_MD, str(out)) import pymupdf as fitz with fitz.open(str(out)) as doc: assert doc.page_count > 0 def test_DC_UNIT_CONVERT_04_pdf_chinese_extractable(self, tmp_path): out = tmp_path / "out.pdf" self._pdf()(_SAMPLE_MD, str(out)) import pymupdf as fitz with fitz.open(str(out)) as doc: text = "".join(page.get_text() for page in doc) assert "占位测试标题" in text # 标题(黑体/NotoSansCJK)精确提取 # 正文中文可提取:CJK 字符充分存在、无豆腐块(无 U+FFFD 替换符) # 注:正文经 NotoSerif/wps 字体 cmap 可能映射为 CJK 兼容字形(落→落 等), # 属 PDF 字体映射现象(记录于 R1 备注),不以精确子串断言正文 cjk = [ch for ch in text if "\u4e00" <= ch <= "\u9fff"] assert len(cjk) >= 20, "正文中文提取量不足" assert "\ufffd" not in text, "出现替换符(豆腐块)" def test_DC_UNIT_CONVERT_05_edge_inputs(self, tmp_path): import pymupdf as fitz for md, name in (("", "empty"), ("# 仅标题", "title-only"), ("长文本" * 5000, "long-line")): pdf = tmp_path / f"{name}.pdf" docx = tmp_path / f"{name}.docx" self._pdf()(md, str(pdf)) self._docx()(md, str(docx)) with fitz.open(str(pdf)) as doc: assert doc.page_count >= 1 # 打开不崩溃(空输入也产出可打开 PDF) def test_DC_UNIT_CONVERT_06_style_hints_consistent(self, tmp_path): bff_parsing = _mod("app.services.docgen.parsing") vstore_parsing = _mod("vstore.parsing") if bff_parsing is None or vstore_parsing is None: pytest.skip("PENDING: 两侧 parsing 未交付") raw = _sample_docx(tmp_path / "s.docx") a = bff_parsing.extract_docx_style(raw) b = vstore_parsing.extract_docx(raw).get("style_hints", {}) assert isinstance(a, dict) and isinstance(b, dict) # 验收口径:两侧公共键值一致(body_font/body_size 等),防导出对齐漂移 common = [k for k in ("body_font", "body_size") if k in a and k in b] for k in common: assert str(a[k]) == str(b[k]), f"style_hints[{k}] 两侧不一致" # ---- 2026-09-04 手工测试需求2:模板 HTML → PDF(预览同源导出) ---- def test_DC_UNIT_CONVERT_07_pdf_from_html_cjk_extractable(self, tmp_path): """模板 HTML → PDF:中文文本必须可提取(lang 字体陷阱回归门禁)。""" pmod = _mod("app.services.docgen.md2pdf") fn = getattr(pmod, "pdf_from_html", None) tpl = _mod("app.services.docgen.templating") if fn is None or tpl is None: pytest.skip("PENDING: pdf_from_html/templating 未交付") import pymupdf html, css = tpl.render_html( "# 关于安全生产的通知\n\n## 一、总体要求\n\n这是正文内容。", title="关于安全生产的通知", key="official", ) out = fn(html, css, str(tmp_path / "out.pdf")) doc = pymupdf.open(str(out)) try: text = "".join(p.get_text() for p in doc) finally: doc.close() assert "关于安全生产的通知" in text assert "这是正文内容" in text # 模板包装层(红头)也进 PDF assert "红头文件" in text # --------------------------------------------------------------------------- # DC-UNIT-PIPELINE-01~08 # --------------------------------------------------------------------------- import asyncio as _asyncio # noqa: E402 TEST_KEY_LLM = None # 占位(不启用) @pytest.mark.needs(["app.services.docgen.pipeline"]) class TestPipeline: @pytest.fixture(autouse=True) def _skip(self, skip_pending, vstore_env): pass def _run(self, tmp_path, monkeypatch, **kw): """按 dev1 权威签名:generate_document(description, *, documents, template)。""" pmod = _mod("app.services.docgen.pipeline") smod = _mod("app.services.docgen.settings") # 制品目录注入 tmp(不碰真实 data/docgen) if smod is not None and hasattr(smod, "set_paths"): smod.set_paths(smod.DocgenPaths(root=str(tmp_path / "docgen"))) # 模版明文拉取走 client.get_document → monkeypatch cmod = _mod("app.services.docgen.client") if cmod is not None: async def _get_doc(doc_id): if doc_id == "tpl1": return { "title": "模版A", "outline": [{"level": 1, "text": s} for s in kw.get("template_outline", ["一、占位第一章", "二、占位第二章"])], "paragraphs": ["占位模版全文"], } return None monkeypatch.setattr(cmod, "get_document", _get_doc) # LLM monkeypatch lmod = _mod("app.services.docgen.llm") async def _text(system, user, **a): return "占位生成段落。\n\n第二段。\n\n- 占位列表项" async def _json(system, user, **a): if kw.get("fail_json"): return {"title": "", "sections": []} return {"title": "占位报告", "sections": ["一、背景", "二、现状", "三、建议"]} monkeypatch.setattr(lmod, "complete_text", _text) monkeypatch.setattr(lmod, "complete_json", _json) # 联网模版参考:默认 mock 为 None(离线纪律,不依赖外网); # 用例可传 web_outline 列表显式走 web 路径 wmod = _mod("app.services.docgen.websearch") async def _search(description): wo = kw.get("web_outline") if not wo: return None return wmod.WebOutline(source_url="http://t.example/f", title="联网范文", outline=list(wo)) monkeypatch.setattr(wmod, "search_template_outline", _search) # humanize:dev1 内部调 humanizer.humanize_answer → monkeypatch try: import app.services.humanizer as hmod monkeypatch.setattr(hmod, "humanize_answer", lambda text: (text, {"rules_applied": 0})) except ImportError: pass fn = getattr(pmod, "generate_document", None) or getattr(pmod, "run_generate", None) assert fn is not None, "pipeline 生成入口未找到" return fn, pmod, kw def _go(self, tmp_path, monkeypatch, **kw): fn, pmod, kw = self._run(tmp_path, monkeypatch, **kw) template = kw.get("template") or ({"id": "tpl1", "name": "模版A"} if kw.get("with_template") else None) result = _asyncio.run(fn( kw.get("description", "帮我写一份年度工作总结"), documents=kw.get("documents", []), template=template, template_style=kw.get("template_style"), )) return result, pmod def test_DC_UNIT_PIPELINE_01_full_flow(self, tmp_path, monkeypatch): result, pmod = self._go(tmp_path, monkeypatch) assert isinstance(result, dict) assert result.get("markdown") assert Path(result["md_path"]).exists() def test_DC_UNIT_PIPELINE_02_template_priority(self, tmp_path, monkeypatch): outline = ["一、模版章节甲", "二、模版章节乙"] result, pmod = self._go(tmp_path, monkeypatch, with_template=True, template_outline=outline) md = result["markdown"] for sec in outline: assert sec in md # 模版大纲章节进入生成(模版优先于 LLM 大纲) def test_DC_UNIT_PIPELINE_03_search_failure_skips_template(self, tmp_path, monkeypatch): # 检索失败 → router 给空模版(template=None);pipeline 正常走无模版路径 result, pmod = self._go(tmp_path, monkeypatch, with_template=False) assert result["markdown"] assert Path(result["md_path"]).exists() def test_DC_UNIT_PIPELINE_04_outline_capped_12(self, tmp_path, monkeypatch): big = [f"第{i}节 占位" for i in range(1, 20)] result, pmod = self._go(tmp_path, monkeypatch, with_template=True, template_outline=big) md = result["markdown"] present = [s for s in big if s in md] assert len(present) <= 12 def test_DC_UNIT_PIPELINE_05_section_failure_placeholder(self, tmp_path, monkeypatch): # 单节生成抛错 → 占位文案,生成不中断(patch 需在 _run 建立默认 mock 之后) fn, pmod, kw = self._run(tmp_path, monkeypatch) lmod = _mod("app.services.docgen.llm") async def _fail_text(system, user, **a): raise _mod("app.services.docgen.llm").LLMError("simulated section failure") monkeypatch.setattr(lmod, "complete_text", _fail_text) result = _asyncio.run(fn( kw.get("description", "帮我写一份年度工作总结"), documents=kw.get("documents", []), template=None, )) assert "(本节生成失败" in result["markdown"] def test_DC_UNIT_PIPELINE_06_json_fallback_six_sections(self, tmp_path, monkeypatch): result, pmod = self._go(tmp_path, monkeypatch, fail_json=True) assert result["markdown"] # 六段式默认大纲兜底 assert "一、背景与目标" in result["markdown"] def test_DC_UNIT_PIPELINE_07_llm_retry_backoff(self): lmod = _mod("app.services.docgen.llm") assert lmod.MAX_ATTEMPTS == 4 # 首试 + 3 次重试 assert tuple(lmod.BACKOFF_SECONDS) == (1.5, 3.0, 6.0) assert lmod.DEFAULT_LLM_TIMEOUT == 300.0 def test_DC_UNIT_PIPELINE_08_humanize_line_protection(self, tmp_path, monkeypatch): """humanize 行保护:标题/表格/列表行原样,仅正文段被改写(spy 统计)。""" pmod = _mod("app.services.docgen.pipeline") # dev1 行保护入口 _humanize_md(generate_document 亦经它);spy 替换 humanize_answer fn = getattr(pmod, "_humanize_md", None) assert fn is not None, "pipeline 行保护入口未找到" calls = [] def spy(text): calls.append(text) return text + "(已润色)", {"rules_applied": 1} monkeypatch.setattr(pmod, "humanize_answer", spy) md_in = "# 标题一\n\n正文段落需要改写。\n\n- 列表行一\n\n| 表头A | 表头B |\n|---|---|\n| 甲 | 乙 |\n\n## 标题二\n\n第二段正文。" out = fn(md_in) # 标题/表格/列表行原样(保护行不经过 humanize) assert "# 标题一" in out assert "- 列表行一" in out assert "| 表头A | 表头B |" in out assert "| 甲 | 乙 |" in out assert "## 标题二" in out # 正文段被改写(spy 收到正文并加标记) assert "(已润色)" in out assert calls, "humanize 未被调用" for c in calls: assert "标题一" not in c and "列表行一" not in c and "| 甲 |" not in c, "保护行进入了 humanize" # ---- 2026-09-04 手工测试需求2:模版 HTML 产出 ---- def test_DC_UNIT_PIPELINE_09_html_output(self, tmp_path, monkeypatch): result, pmod = self._go(tmp_path, monkeypatch, with_template=False) assert result["markdown"] # 模版 HTML 产出(预览与导出同源) assert result.get("html") and "
" in result["html"] assert result.get("css") assert result.get("template_key") # 分类出内置模版 key assert result.get("outline_source") in ("llm", "default") assert Path(result["md_path"]).exists() # 2026-09-04 手工测试问题3:文件名 = LLM 提炼标题(mock 返回「占位报告」) assert result.get("file_name") == "占位报告" def test_DC_UNIT_PIPELINE_09b_file_name_rule(self, tmp_path, monkeypatch): # 2026-09-04 手工测试问题3:无 LLM 标题路径 → prompt 核心短语提炼 result, pmod = self._go( tmp_path, monkeypatch, with_template=True, description="帮我写一个十一放假通知,放假时间为10月1日,红头用三亚海古纪生物科技有限公司", ) assert result.get("file_name") == "十一放假通知" def test_DC_UNIT_PIPELINE_10_web_outline_seed(self, tmp_path, monkeypatch): web = ["一、联网章节甲", "二、联网章节乙"] result, pmod = self._go(tmp_path, monkeypatch, with_template=False, web_outline=web) md = result["markdown"] assert result["outline_source"] == "web" for sec in web: assert sec in md # 联网范文结构成为大纲种子 def test_DC_UNIT_PIPELINE_11_template_style_overlay(self, tmp_path, monkeypatch): result, pmod = self._go(tmp_path, monkeypatch, with_template=True, template_style={"body_font": "宋体", "body_size": 14, "headings": {"1": {"font": "黑体", "size": 18}}}) css = result.get("css") or "" assert "宋体" in css assert "font-size: 14.0pt" in css # --------------------------------------------------------------------------- # DC-UNIT-EDITOR-01~03 # --------------------------------------------------------------------------- @pytest.mark.needs(["app.services.docgen.editor"]) class TestEditor: @pytest.fixture(autouse=True) def _skip(self, skip_pending, vstore_env): pass def test_DC_UNIT_EDITOR_01_apply_edit_full_md_and_structure(self, monkeypatch): """v1.1 强化:编辑前后标题结构(H1/H2 序列)一致——「编辑破坏文档」对策。""" emod = _mod("app.services.docgen.editor") lmod = _mod("app.services.docgen.llm") async def fake_complete(system, user, **kw): # 模拟 LLM:标题行原样、正文行改写(与行保护语义一致) out = [] for line in user.split("\n"): if line.startswith("#"): out.append(line) elif line.strip(): out.append(line + "(已改写)") return "\n".join(out) monkeypatch.setattr(lmod, "complete_text", fake_complete) md_in = "# 第一章 概述\n\n正文一。\n\n## 1.1 背景\n\n正文二。\n\n## 1.2 目标\n\n正文三。" result = _asyncio.run(emod.apply_edit(md_in, "润色一下")) assert isinstance(result, dict) out = result.get("markdown", "") assert "第一章 概述" in out and "1.1 背景" in out # 完整新 md(含标题),非片段 heads_in = [l for l in md_in.split("\n") if l.startswith("#")] heads_out = [l for l in out.split("\n") if l.startswith("#")] assert [h.split(" ")[0] for h in heads_in] == [h.split(" ")[0] for h in heads_out], "标题层级序列被破坏" def test_DC_UNIT_EDITOR_02_smart_export(self, tmp_path, monkeypatch): emod = _mod("app.services.docgen.editor") smod = _mod("app.services.docgen.settings") if smod is not None and hasattr(smod, "set_paths"): smod.set_paths(smod.DocgenPaths(root=str(tmp_path / "docgen"))) pdf = emod.smart_export(_SAMPLE_MD, "pdf", None) docx = emod.smart_export(_SAMPLE_MD, "docx", None) assert pdf is not None and Path(str(pdf)).exists() assert docx is not None and Path(str(docx)).exists() def test_DC_UNIT_EDITOR_03_error_propagation(self, monkeypatch): """LLM 失败:短文档向上抛 LLMError(dev1 契约;router 转 INTERNAL 信封,不吞失败)。""" emod = _mod("app.services.docgen.editor") lmod = _mod("app.services.docgen.llm") async def bad(system, user, **kw): raise lmod.LLMError("simulated llm down") monkeypatch.setattr(lmod, "complete_text", bad) with pytest.raises(lmod.LLMError): _asyncio.run(emod.apply_edit("旧正文", "改一下")) # --------------------------------------------------------------------------- # DC-UNIT-CLIENT-01~05 # --------------------------------------------------------------------------- @pytest.mark.needs(["app.services.docgen.client"]) class TestClient: @pytest.fixture(autouse=True) def _skip(self, skip_pending): pass def test_DC_UNIT_CLIENT_01_request_shapes(self): cmod = _mod("app.services.docgen.client") for name in ("ingest_bytes", "search_documents", "get_document", "get_style", "delete_document", "update_metadata", "rebuild_index", "health"): assert hasattr(cmod, name), f"client.{name} 不存在" def test_DC_UNIT_CLIENT_02_retry_then_error(self, monkeypatch): cmod = _mod("app.services.docgen.client") attempts = {"n": 0} class FakeClient: async def request(self, method, path, **kw): attempts["n"] += 1 import httpx raise httpx.ConnectError("simulated down") async def aclose(self): pass monkeypatch.setattr(cmod, "set_client", lambda c: None) monkeypatch.setattr(cmod, "get_client", lambda: FakeClient()) with pytest.raises(cmod.DocgenUnavailable): _asyncio.run(cmod.search_documents("x", top_k=1)) assert attempts["n"] == cmod.MAX_RETRIES + 1 # 首试 + 3 次重试,不无限重试 def test_DC_UNIT_CLIENT_03_connect_error_no_raise(self, monkeypatch): # 契约:网络错误抛 DocgenUnavailable(router 捕获兜底),不抛裸 httpx 异常 cmod = _mod("app.services.docgen.client") class FakeClient: async def request(self, method, path, **kw): import httpx raise httpx.ConnectError("simulated vstore down") async def aclose(self): pass monkeypatch.setattr(cmod, "get_client", lambda: FakeClient()) with pytest.raises(cmod.DocgenUnavailable): _asyncio.run(cmod.search_documents("x", top_k=1)) def test_DC_UNIT_CLIENT_04_error_normalized(self, monkeypatch): cmod = _mod("app.services.docgen.client") import httpx class FakeClient: async def request(self, method, path, **kw): return httpx.Response(500, json={"detail": "boom"}) async def aclose(self): pass monkeypatch.setattr(cmod, "get_client", lambda: FakeClient()) # 5xx → 重试尽 → DocgenUnavailable(router 层转错误信封) with pytest.raises(cmod.DocgenUnavailable): _asyncio.run(cmod.search_documents("x", top_k=1)) def test_DC_UNIT_CLIENT_05_constants_align(self): bff = _mod("app.services.docgen.settings") vst = _mod("vstore.settings") if bff is None or vst is None: pytest.skip("PENDING: settings 未交付") for k in ("MAX_SECTIONS", "MAX_OUTLINE_CHUNKS"): a, b = getattr(bff, k, None), getattr(vst, k, None) if a is None or b is None: pytest.skip(f"{k} 未在两侧定义") assert a == b, f"{k} 两侧常量不一致" # 2026-09-04:上传限制常量两侧均已移除 assert not hasattr(bff, "MAX_ATTACH_COUNT") and not hasattr(vst, "MAX_ATTACH_COUNT") assert not hasattr(bff, "MAX_ATTACH_BYTES") and not hasattr(vst, "MAX_ATTACH_BYTES") assert not hasattr(bff, "MAX_ATTACH_CHARS") and not hasattr(vst, "MAX_ATTACH_CHARS")