Files
paper-doc/backend/scripts/smoke_papers.py
T
govin 06d7e922bd backend: add the paper authoring schema, API, and smoke test
A paper is a document written against a template. Its structure is never
copied into it: `paper` points at a template and the outline is read live on
every render, so switching `template_id` re-shapes the whole document in one
write.

Sentences are addressed by *position* rather than by a template row id:
`paper_sentence.paper_template_filed_sort` holds the sort of the placement the
sentence belongs to, and `sort` holds its place inside that paragraph. That
indirection is what makes a template switch non-destructive — a sentence that
remembers "position 7" lands on whatever the new template puts at position 7 —
and it is why a paragraph is any position either the template or the content
mentions: the structure survives with no content, and content whose position
the template does not define is still rendered, in order, under 未设定.

Citations are a table rather than a column, since one sentence may quote
several references. `quote` is required — a citation that does not say what it
quotes is refused with 422 — while `reference_id` is a plain nullable integer
with no foreign key, because the reference library does not exist yet.

Deleting a template a paper is written against is refused with 409 and a count,
matching how the field library refuses to drop a field still in use.

scripts/smoke_papers.py walks the whole loop — create, empty structure, write a
paragraph with citations, switch templates, keep unmatched positions, move a
paragraph, delete — in 40 checks, and cleans up after itself.
2026-09-18 17:29:12 +08:00

329 lines
14 KiB
Python

"""End-to-end smoke test for the paper feature, against a running API.
Run it while the backend is up::
.venv/bin/python scripts/smoke_papers.py
It walks the whole writing loop — create a paper on a template, verify the
empty structure, fill a paragraph with sentences and citations, read the
document back, switch the template, and check that unmatched positions survive
under an unset heading — then deletes everything it created. Exits non-zero on
the first failed expectation, so it is usable as a gate.
"""
from __future__ import annotations
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
BASE = "http://127.0.0.1:8000/api"
_checks = 0
def call(method: str, path: str, body: Any = None, expect: int = 200) -> Any:
"""Perform one API call and assert its status code.
The path is quoted so a Chinese search keyword travels as UTF-8 percent
escapes instead of blowing up the ASCII request line.
"""
data = json.dumps(body).encode() if body is not None else None
url = BASE + urllib.parse.quote(path, safe="/?&=%")
request = urllib.request.Request(
url,
data=data,
method=method,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=20) as response:
status = response.status
payload = response.read().decode()
except urllib.error.HTTPError as error:
status = error.code
payload = error.read().decode()
if status != expect:
raise AssertionError(f"{method} {path} -> {status}, expected {expect}: {payload}")
return json.loads(payload) if payload else None
def check(label: str, condition: bool, detail: str = "") -> None:
"""Assert one expectation and report it."""
global _checks
_checks += 1
if not condition:
raise AssertionError(f"{label}: {detail}")
print(f" ok {label}")
def main() -> int:
created_papers: list[int] = []
try:
print("1. pick two templates with different paragraph positions")
templates = call("GET", "/templates?page_size=50")["items"]
check("templates exist", len(templates) >= 2, f"got {len(templates)}")
wide = max(templates, key=lambda item: item["field_count"])
narrow = min(templates, key=lambda item: item["field_count"])
wide_full = call("GET", f"/templates/{wide['id']}")
narrow_full = call("GET", f"/templates/{narrow['id']}")
print(f" wide={wide['name']} ({wide['field_count']}), narrow={narrow['name']}")
print("2. create a paper")
paper = call(
"POST",
"/papers",
{
"title": "冒烟测试论文",
"template_id": wide["id"],
"author": "测试作者",
"status": "writing",
"keywords": "关键词A,关键词B;关键词A",
"target_journal": "测试期刊",
"abstract": "用于验证论文功能的临时数据。",
},
expect=201,
)
created_papers.append(paper["id"])
check("keywords normalised", paper["keywords"] == "关键词A, 关键词B", paper["keywords"])
check(
"paragraph denominator counts the template",
paper["template_paragraph_count"] == len({f["sort"] for f in wide_full["fields"]}),
str(paper["template_paragraph_count"]),
)
check("no sentences yet", paper["sentence_count"] == 0)
print("3. the empty paper already has its structure")
document = call("GET", f"/papers/{paper['id']}/document")
check(
"every template position is a paragraph",
len(document["paragraphs"]) == len({f["sort"] for f in wide_full["fields"]}),
str(len(document["paragraphs"])),
)
check("all paragraphs empty", all(not p["sentences"] for p in document["paragraphs"]))
check("all paragraphs matched", all(p["matched"] for p in document["paragraphs"]))
check("no citations", document["citations"] == [])
first_sort = document["paragraphs"][0]["paper_template_filed_sort"]
print("4. write a paragraph: two sentences, citations on both")
document = call(
"PUT",
f"/papers/{paper['id']}/paragraphs/{first_sort}",
{
"sentences": [
{
"content": " 第一句话,用于验证空白折叠。 ",
"citations": [{"reference_id": 7, "quote": "被引用的第一段内容"}],
},
{
"content": "第二句话。",
"citations": [
{"reference_id": 9, "quote": "引文甲"},
{"quote": "只有引用内容、暂时没有引用 id"},
],
},
]
},
)
paragraph = document["paragraphs"][0]
check("two sentences", len(paragraph["sentences"]) == 2, str(len(paragraph["sentences"])))
check("content trimmed", paragraph["sentences"][0]["content"] == "第一句话,用于验证空白折叠。")
check(
"sorts numbered from 1",
[s["sort"] for s in paragraph["sentences"]] == [1, 2],
str([s["sort"] for s in paragraph["sentences"]]),
)
check("citations numbered", [c["sort"] for c in paragraph["sentences"][1]["citations"]] == [1, 2])
check("three citations in the paper", len(document["citations"]) == 3)
check(
"citation index is reading order",
[c["index"] for c in document["citations"]] == [1, 2, 3],
str([c["index"] for c in document["citations"]]),
)
check("sentence template stamped", paragraph["sentences"][0]["template_id"] == wide["id"])
print("5. explicit sorts decide the order, not the order they were sent")
second_sort = document["paragraphs"][1]["paper_template_filed_sort"]
reordered = call(
"PUT",
f"/papers/{paper['id']}/paragraphs/{second_sort}",
{
"sentences": [
{"sort": 20, "content": "排序在后的句子。"},
{"sort": 10, "content": "排序在前的句子。"},
]
},
)
second = next(
p
for p in reordered["paragraphs"]
if p["paper_template_filed_sort"] == second_sort
)
check(
"ascending sort wins over arrival order",
[s["content"] for s in second["sentences"]]
== ["排序在前的句子。", "排序在后的句子。"],
str([s["content"] for s in second["sentences"]]),
)
check("explicit sorts are stored as sent", [s["sort"] for s in second["sentences"]] == [10, 20])
# Clear it again so the counts asserted below stay meaningful.
call(
"PUT",
f"/papers/{paper['id']}/paragraphs/{second_sort}",
{"sentences": []},
)
print("6. reject a citation with no quote")
bad = call(
"PUT",
f"/papers/{paper['id']}/paragraphs/{first_sort}",
{"sentences": [{"content": "x", "citations": [{"reference_id": 3, "quote": " "}]}]},
expect=422,
)
check("citation without content refused", "detail" in bad)
print("7. a sentence with no paragraph in the template survives a switch")
# A position neither template defines, so the check means the same
# thing before and after the switch below.
orphan_sort = (
max(f["sort"] for f in wide_full["fields"] + narrow_full["fields"]) + 5
)
call(
"POST",
f"/papers/{paper['id']}/sentences",
{"paper_template_filed_sort": orphan_sort, "content": "新模板里没有这个位置的句子。"},
expect=201,
)
document = call("GET", f"/papers/{paper['id']}/document")
check(
"orphan position is rendered",
any(
p["paper_template_filed_sort"] == orphan_sort and not p["matched"]
for p in document["paragraphs"]
),
)
check("orphan heading has no name", next(
p for p in document["paragraphs"] if p["paper_template_filed_sort"] == orphan_sort
)["name"] is None)
print("8. switch the template")
switched = call("PATCH", f"/papers/{paper['id']}", {"template_id": narrow["id"]})
check("template changed", switched["template_id"] == narrow["id"])
document = call("GET", f"/papers/{paper['id']}/document")
matched = [p for p in document["paragraphs"] if p["matched"]]
unmatched = [p for p in document["paragraphs"] if not p["matched"]]
check(
"structure follows the new template",
len(matched) == len({f["sort"] for f in narrow_full["fields"]}),
str(len(matched)),
)
check("unmatched positions kept", len(unmatched) >= 1, str(len(unmatched)))
check(
"all sentences still present",
sum(len(p["sentences"]) for p in document["paragraphs"]) == 3,
)
check(
"positions ascend",
[p["paper_template_filed_sort"] for p in document["paragraphs"]]
== sorted(p["paper_template_filed_sort"] for p in document["paragraphs"]),
)
check(
"sentences re-stamped with the new template",
all(
s["template_id"] == narrow["id"]
for p in document["paragraphs"]
for s in p["sentences"]
),
)
print("9. move one paragraph onto another position")
document = call(
"PUT",
f"/papers/{paper['id']}/paragraphs/{orphan_sort}",
{"sentences": [{"content": "搬到别处的一句。"}], "target_sort": first_sort},
)
target = next(
p for p in document["paragraphs"] if p["paper_template_filed_sort"] == first_sort
)
check("moved sentence appended after the existing ones", len(target["sentences"]) == 3)
check(
"source position gone",
all(p["paper_template_filed_sort"] != orphan_sort for p in document["paragraphs"]),
)
print("10. paper list reports progress")
listing = call("GET", f"/papers?keyword=冒烟&status=writing")
check("found by keyword and status", listing["total"] == 1, str(listing["total"]))
row = listing["items"][0]
check("paragraph count", row["paragraph_count"] == 1, str(row["paragraph_count"]))
check("sentence count", row["sentence_count"] == 3, str(row["sentence_count"]))
print("11. a template in use cannot be deleted")
conflict = call("DELETE", f"/templates/{narrow['id']}", expect=409)
check("template delete refused", "正被论文使用" in conflict["detail"], conflict["detail"])
print("12. single sentence edit and delete")
sentence_id = target["sentences"][0]["id"]
updated = call(
"PATCH",
f"/papers/{paper['id']}/sentences/{sentence_id}",
{"content": "改写后的一句话。", "sort": 5},
)
check("sentence updated", updated["content"] == "改写后的一句话。")
check("sentence sort updated", updated["sort"] == 5)
call("DELETE", f"/papers/{paper['id']}/sentences/{sentence_id}", expect=204)
document = call("GET", f"/papers/{paper['id']}/document")
check(
"sentence deleted",
sum(len(p["sentences"]) for p in document["paragraphs"]) == 2,
)
print("13. a paper with no template still works")
bare = call(
"POST",
"/papers",
{"title": "冒烟测试-无模板论文", "template_id": None, "status": "draft"},
expect=201,
)
created_papers.append(bare["id"])
empty_doc = call("GET", f"/papers/{bare['id']}/document")
check("no template means no structure", empty_doc["paragraphs"] == [])
call(
"POST",
f"/papers/{bare['id']}/sentences",
{"paper_template_filed_sort": 1, "content": "没有模板时写下的第一句。"},
expect=201,
)
bare_doc = call("GET", f"/papers/{bare['id']}/document")
check("content creates its own paragraph", len(bare_doc["paragraphs"]) == 1)
check("that paragraph has no heading", bare_doc["paragraphs"][0]["matched"] is False)
check("and the reader is warned", len(bare_doc["warnings"]) >= 1)
call("DELETE", f"/papers/{bare['id']}", expect=204)
created_papers.remove(bare["id"])
print("14. delete the paper")
call("DELETE", f"/papers/{paper['id']}", expect=204)
created_papers.clear()
check("gone from the list", call("GET", "/papers")["total"] == 0)
print(f"\nall {_checks} checks passed")
return 0
finally:
# Never leave test rows behind, even on a failed expectation.
for paper_id in created_papers:
try:
call("DELETE", f"/papers/{paper_id}", expect=204)
print(f"cleaned up paper {paper_id}")
except Exception as error: # noqa: BLE001 - cleanup must not mask the failure
print(f"cleanup of paper {paper_id} failed: {error}", file=sys.stderr)
if __name__ == "__main__":
sys.exit(main())