- 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
408 lines
15 KiB
Python
408 lines
15 KiB
Python
"""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"]
|