Files
PlantAI-rag-new/tests/docgen/test_unit_acceptance.py
T
jerryandClaude Fable 5 4749f77544 fix(docgen): 两批手工测试修复——上传/预览/检索 + 护栏/导出/命名/编辑还原/二次预览
第一批:上传去限制+进度展示、模板HTML预览+按需导出、检索TAB实时更新
第二批:edit护栏移除、导出按钮纯化+file_name落库、编辑失焦还原、
       二次预览白底根治(防竞态+渲染兜底)
模板体系:7套公文模板(templates/)+templating渲染器、websearch联网

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-05 15:49:54 +08:00

1005 lines
42 KiB
Python
Raw 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 -*-
"""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 "<main>" 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")