06d7e922bd
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.
329 lines
14 KiB
Python
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())
|