第一批:上传去限制+进度展示、模板HTML预览+按需导出、检索TAB实时更新
第二批:edit护栏移除、导出按钮纯化+file_name落库、编辑失焦还原、
二次预览白底根治(防竞态+渲染兜底)
模板体系:7套公文模板(templates/)+templating渲染器、websearch联网
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1005 lines
42 KiB
Python
1005 lines
42 KiB
Python
# -*- 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")
|