Files
PlantAI-rag-new/review_service/tests/test_reformat.py
potaser 42aeb8fa4c feat(review): paper auto-reformat P1 (.tex rule engine + diff panel)
- Rule engine: font/margin/line-spacing/bib-style transformers,
  tighten-only + structural guard + idempotency
- POST/GET /api/review/reformat with 200-envelope errors and job store
- ReformatPanel: journal dropdown (featured + directory search),
  source/diff tabs; compile tab is a dev placeholder for now
- journals.json format_rules_default kept as generic fallback
2026-10-11 12:19:17 +08:00

408 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""tests/test_reformat.py —— P1 论文重排版单元测试(离线,零 LLM/零网络/零磁盘污染)。
覆盖:规则解析链(default/种子占位/目录回链/抓取缓存/未匹配)+ 4 变换器
(改/不改/保守 warning)+ 结构守卫 + 幂等 + API 信封(校验/落盘/GET/404)。
运行: cd review_service && <venv>/bin/python -m pytest -q
"""
from __future__ import annotations
import asyncio
import re
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.services import reformat as rf
from app.services.reformat import RULES_DEFAULT, ReformatError, reformat_tex, resolve_rules
# 金样例:4 处违规(字号/边距/行距/样式),全部命令齐备 → 0 warning
GOLDEN = r"""\documentclass[10pt]{article}
\usepackage[margin=1in,top=10mm]{geometry}
\linespread{1.0}
\bibliographystyle{plain}
\begin{document}
Hello.
\end{document}
"""
MINIMAL = "\\documentclass[11pt]{article}\n\\begin{document}\nx\n\\end{document}\n"
def _run(source: str, **rule_overrides):
rules = {**RULES_DEFAULT, **rule_overrides}
return reformat_tex(source, rules)
def _modified(changes):
return [c for c in changes if c["action"] == "modified"]
def _warnings(changes):
return [c for c in changes if c["action"] == "warning"]
def _by_key(changes, rule_key, action=None):
out = [c for c in changes if c["rule_key"] == rule_key
and (action is None or c["action"] == action)]
return out
# ================================================================ 规则解析链
def test_resolve_default():
r = asyncio.run(resolve_rules("default"))
assert r["rules_source"] == "default"
assert r["rules"] == RULES_DEFAULT
assert r["rules"]["min_font_pt"] == 11
assert r["rules"]["min_margin_mm"] == 20
assert r["rules"]["min_line_spacing"] == 1.5
assert r["rules"]["refs_style"] == "apa"
assert r["journal_id"] == "default"
def test_resolve_seed_name_official():
r = asyncio.run(resolve_rules("Genome Biology"))
assert r["journal_id"] == "genome-biology"
assert r["rules_source"] == "official"
assert r["rules"]["min_line_spacing"] == 2.0
assert r["rules"]["refs_style"] == "vancouver"
def test_resolve_seed_id():
r = asyncio.run(resolve_rules("genome-biology"))
assert r["journal_id"] == "genome-biology"
assert r["rules_source"] == "official"
def test_resolve_unknown_name():
r = asyncio.run(resolve_rules("Nonexistent Journal of Nowhere"))
assert r["rules_source"] == "default"
assert r["journal_id"] is None
assert "未匹配" in r["rules_note"]
def test_resolve_directory_id_backlink(monkeypatch):
async def fake_dir_name(journal_id: str):
return "Genome Biology" if journal_id.startswith("zh:") else None
monkeypatch.setattr(rf, "_directory_name", fake_dir_name)
r = asyncio.run(resolve_rules("zh:some-slug"))
assert r["journal_id"] == "genome-biology"
def test_resolve_fetched_cache(monkeypatch):
monkeypatch.setattr(rf, "_cache_rules", lambda jid: {
"rules_source": "fetched", "min_font_pt": 10, "refs_style": "gb7714"})
r = asyncio.run(resolve_rules("genome-biology"))
assert r["rules_source"] == "fetched" # 抓取缓存优先于 official
assert r["rules"]["min_font_pt"] == 10 # 专属覆盖通用
assert r["rules"]["min_margin_mm"] == 20 # 缺失键保留通用
assert r["rules"]["refs_style"] == "gb7714"
def test_resolve_official_format_rules(monkeypatch):
monkeypatch.setattr(rf, "_find_seed", lambda text: {
"id": "x-journal", "name": "X Journal",
"format_rules": {"rules_source": "official", "min_line_spacing": 1.3}})
r = asyncio.run(resolve_rules("X Journal"))
assert r["rules_source"] == "official"
assert r["rules"]["min_line_spacing"] == 1.3
# ================================================================ ① 字号 font_pt
def test_font_modify_below_min():
src = "\\documentclass[10pt]{article}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src)
font = _by_key(res["changes"], "font_pt")
assert len(font) == 1
c = font[0]
assert c["action"] == "modified"
assert "[11pt]" in res["modified_tex"] and "[10pt]" not in res["modified_tex"]
assert "10pt" in c["original"] and "11pt" in c["modified"]
def test_font_compliant_untouched():
res = _run(MINIMAL)
assert res["modified_tex"] == MINIMAL # warning 不改源码(拍板 2)
assert _by_key(res["changes"], "font_pt") == []
def test_font_missing_option_is_warning():
src = "\\documentclass[a4paper]{article}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src)
assert res["modified_tex"] == src # 拍板 2:绝不自动插入选项
(c,) = _by_key(res["changes"], "font_pt")
assert c["action"] == "warning"
assert "\\documentclass[a4paper, 11pt]{article}" in c["reason"]
def test_font_min_above_standard_uses_min():
src = "\\documentclass[12pt]{article}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src, min_font_pt=14)
assert "[14pt]" in res["modified_tex"]
# ================================================================ ② 页边距 margin
def test_margin_bumps_hmargin_vmargin():
src = "\\documentclass[11pt]{article}\n\\usepackage[hmargin=10mm,vmargin=15mm]{geometry}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src)
assert "hmargin=20mm" in res["modified_tex"]
assert "vmargin=20mm" in res["modified_tex"]
assert "]{geometry}" in res["modified_tex"] # 值替换不得吞掉包名尾巴
margin = _by_key(res["changes"], "margin")
assert len(margin) == 1 # 同行多键合并一条
assert "left" in margin[0]["reason"] and "top" in margin[0]["reason"]
def test_margin_per_side_only_bumps_violations():
res = _run(GOLDEN)
(c,) = [x for x in res["changes"] if x["rule_key"] == "margin"]
assert c["action"] == "modified"
assert "top=20mm" in c["modified"] # top 10mm 违规
assert "margin=1in" in c["modified"] # 1in=25.4mm 合规,不动
def test_margin_compliant_untouched():
src = "\\documentclass[11pt]{article}\n\\usepackage[margin=25mm]{geometry}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src)
assert res["modified_tex"] == src
def test_margin_no_geometry_is_warning():
src = "\\documentclass[11pt]{article}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src)
assert res["modified_tex"] == src
(c,) = [x for x in res["changes"] if x["rule_key"] == "margin"]
assert c["action"] == "warning"
assert "\\usepackage[margin=20mm]{geometry}" in c["reason"]
def test_margin_geometry_without_margin_attrs_is_warning():
src = "\\documentclass[11pt]{article}\n\\usepackage[scale=0.7]{geometry}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src)
assert res["modified_tex"] == src
(c,) = [x for x in res["changes"] if x["rule_key"] == "margin"]
assert c["action"] == "warning"
assert "margin=20mm" in c["reason"]
def test_margin_partial_sides_bump_and_warn():
src = "\\documentclass[11pt]{article}\n\\usepackage[left=10mm]{geometry}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src)
assert "left=20mm" in res["modified_tex"]
assert "]{geometry}" in res["modified_tex"]
mod = _by_key(res["changes"], "margin", "modified")
warn = _by_key(res["changes"], "margin", "warning")
assert len(mod) == 1 and len(warn) == 1
assert "right" in warn[0]["reason"]
# ================================================================ ③ 行距 line_spacing
def test_spacing_modify():
res = _run(GOLDEN)
(c,) = [x for x in res["changes"] if x["rule_key"] == "line_spacing"]
assert c["action"] == "modified"
assert "\\linespread{1.5}" in res["modified_tex"]
def test_spacing_ignores_body_override():
src = ("\\documentclass[11pt]{article}\n\\linespread{1.5}\n\\begin{document}\n"
"\\setstretch{1.0}\nx\n\\end{document}\n")
res = _run(src)
assert res["modified_tex"] == src # 正文局部覆盖不碰(导言区已合规)
def test_spacing_missing_is_warning():
src = "\\documentclass[11pt]{article}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src)
(c,) = [x for x in res["changes"] if x["rule_key"] == "line_spacing"]
assert c["action"] == "warning"
assert "\\linespread{1.5}" in c["reason"]
# ================================================================ ④ 参考文献 bib_style
def test_bib_plain_to_apalike():
res = _run(GOLDEN)
(c,) = [x for x in res["changes"] if x["rule_key"] == "bib_style"]
assert c["action"] == "modified"
assert "\\bibliographystyle{apalike}" in res["modified_tex"]
def test_bib_author_year_maps_apalike_not_plain():
"""回归(2026-10-10 修正):author_year 是姓名-年份制 → apalike,绝不映射编号制 plain。"""
src = "\\documentclass[11pt]{article}\n\\bibliographystyle{unsrt}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src, refs_style="author_year")
assert "\\bibliographystyle{apalike}" in res["modified_tex"]
assert "unsrt" not in res["modified_tex"].split("bibliographystyle")[1]
def test_bib_null_refs_style_warns_without_touching_source():
"""拍板(2026-10-10):refs_style=null(如 Frontiers 两种制式并存)→ 保持原样 + warning。"""
src = "\\documentclass[11pt]{article}\n\\bibliographystyle{unsrt}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src, refs_style=None)
assert res["modified_tex"] == src # 绝不猜测改写用户样式
(c,) = _by_key(res["changes"], "bib_style")
assert c["action"] == "warning"
assert "未确认" in c["reason"] and "bibliographystyle" in c["reason"]
def test_bib_gb7714_hint():
src = "\\documentclass[11pt]{article}\n\\bibliographystyle{plain}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src, refs_style="gb7714")
assert "\\bibliographystyle{gb7714}" in res["modified_tex"]
(c,) = _by_key(res["changes"], "bib_style")
assert "gbt7714" in c["reason"]
def test_bib_unmapped_style_is_warning():
src = "\\documentclass[11pt]{article}\n\\bibliographystyle{plain}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src, refs_style="mla")
assert res["modified_tex"] == src
(c,) = _by_key(res["changes"], "bib_style")
assert c["action"] == "warning" and "mla" in c["reason"]
def test_bib_missing_is_warning():
src = "\\documentclass[11pt]{article}\n\\begin{document}\nx\n\\end{document}\n"
res = _run(src)
(c,) = [x for x in res["changes"] if x["rule_key"] == "bib_style"]
assert c["action"] == "warning"
assert "\\bibliographystyle{apalike}" in c["reason"]
def test_bib_biblatex_workflow_is_warning():
src = ("\\documentclass[11pt]{article}\n\\usepackage{biblatex}\n"
"\\begin{document}\n\\printbibliography\n\\end{document}\n")
res = _run(src)
(c,) = [x for x in res["changes"] if x["rule_key"] == "bib_style"]
assert c["action"] == "warning"
assert "style=apa" in c["reason"]
# ================================================================ 结构守卫 + 幂等
def test_parser_failed_without_documentclass():
with pytest.raises(ReformatError) as ei:
_run("% just a fragment\n")
assert ei.value.code == "PARSER_FAILED"
def test_multi_documentclass_first_wins():
src = ("\\documentclass[10pt]{article}\n\\begin{document}\n"
"\\documentclass[9pt]{book}\n\\end{document}\n")
res = _run(src)
assert [w["code"] for w in res["warnings"]] == ["MULTI_DOCUMENTCLASS"]
assert "[11pt]" in res["modified_tex"]
assert res["modified_tex"].count("\\documentclass") == 2 # 第二处不碰
def test_idempotent_second_run_clean():
first = _run(GOLDEN)
assert len(_modified(first["changes"])) == 4
assert first["warnings"] == []
second = _run(first["modified_tex"])
assert second["changes"] == []
assert second["modified_tex"] == first["modified_tex"]
def test_changes_sorted_and_fields_complete():
res = _run(GOLDEN)
lines = [c["line"] for c in res["changes"]]
assert lines == sorted(lines)
for c in res["changes"]:
assert set(c) == {"line", "original", "modified", "reason", "rule_key", "action"}
# ================================================================ API(信封/落盘)
def _post(client: TestClient, content: bytes, journal: str = "default",
filename: str = "paper.tex", **data):
if journal is not None:
data.setdefault("target_journal", journal)
return client.post("/api/review/reformat",
files={"file": (filename, content, "text/plain")}, data=data)
@pytest.fixture()
def client(tmp_path, monkeypatch):
jobs = tmp_path / "jobs"
def fake_dir():
jobs.mkdir(parents=True, exist_ok=True)
return jobs
monkeypatch.setattr("app.routers.reformat._jobs_dir", fake_dir)
return TestClient(app)
def test_api_non_tex_rejected(client: TestClient):
body = _post(client, b"x", filename="a.pdf").json()
assert body["ok"] is False
assert body["error"]["code"] == "VALIDATION"
assert body["error"]["degraded"] is False
def test_api_missing_target_rejected(client: TestClient):
body = _post(client, GOLDEN.encode(), journal=None).json()
assert body["ok"] is False and body["error"]["code"] == "VALIDATION"
def test_api_empty_file_rejected(client: TestClient):
body = _post(client, b"").json()
assert body["ok"] is False and body["error"]["code"] == "VALIDATION"
def test_api_non_utf8_rejected(client: TestClient):
body = _post(client, b"\xff\xfe\x00bad").json()
assert body["ok"] is False and body["error"]["code"] == "VALIDATION"
def test_api_parser_failed_envelope(client: TestClient):
body = _post(client, b"% fragment, no documentclass").json()
assert body["ok"] is False and body["error"]["code"] == "PARSER_FAILED"
assert "documentclass" in body["error"]["message"]
def test_api_success_and_persist(client: TestClient):
body = _post(client, GOLDEN.encode()).json()
assert body["ok"] is True
assert re.fullmatch(r"[0-9a-f]{12}", body["job_id"])
mod = _modified(body["changes"])
assert len(mod) == 4 and body["warnings"] == []
assert "\\documentclass[11pt]" in body["modified_tex"]
meta = body["meta"]
assert meta["rules_source"] == "default"
assert meta["rules"]["min_font_pt"] == 11
assert meta["engine"] == "reformat-p1/1.0"
assert len(meta["original_sha256"]) == 64
# 持久化:重启语义的 GET 回读同一 job
got = client.get(f"/api/review/reformat/{body['job_id']}").json()
assert got["ok"] and got["job_id"] == body["job_id"]
assert len(_modified(got["changes"])) == 4
f = client.get(f"/api/review/reformat/{body['job_id']}/file")
assert f.status_code == 200
assert "x-tex" in f.headers["content-type"]
assert "\\documentclass[11pt]" in f.text
def test_api_job_not_found(client: TestClient):
for path in ("/api/review/reformat/000000000000",
"/api/review/reformat/zzzz"):
body = client.get(path).json()
assert body["ok"] is False and body["error"]["code"] == "NOT_FOUND"
f = client.get("/api/review/reformat/000000000000/file")
assert f.status_code == 200 and f.json()["error"]["code"] == "NOT_FOUND"
def test_api_idempotent_reupload(client: TestClient):
first = _post(client, GOLDEN.encode()).json()
assert first["ok"] and len(_modified(first["changes"])) == 4
second = _post(client, first["modified_tex"].encode()).json()
assert second["ok"] and second["changes"] == []
assert second["modified_tex"] == first["modified_tex"]