45926af966
A template's abstract paragraph (`0 Abstract`) *is* the paper's abstract: it has a position in the document, the heading and typography the template gives it, and the same sentence-by-sentence editing as everything else. `paper.abstract` was a second home for that text — one the document never reads, so a paper could show two different abstracts and the column could drift from the body. The column is gone, from the table, the model, the schemas, the API payloads, the paper form, the list subtitle and the paper page. Nothing else changed. The text already written into it is not gone. Revision a83f5c21d7b6 writes each stored abstract into the paper's body first — one sentence per 。 at the paragraph carrying the abstract heading, appended after anything already there rather than replacing it. A template without such a heading keeps the text too, one position above its first paragraph, where the document renders it under 未设定. Verified against the one paper that had an abstract: 450 characters in, 450 out, identical including `|J| ≤ α · I⁻ᵝ`, split into six sentences in the `0 Abstract` paragraph, still with its own edit button. The smoke test no longer assumes an empty database: it records the paper total before it starts and compares against that, so it can run on a real one.
337 lines
14 KiB
Python
337 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:
|
|
# The database may already hold real papers, so "nothing of ours is
|
|
# left" is measured against a baseline rather than against zero.
|
|
baseline = call("GET", "/papers")["total"]
|
|
print(f"0. baseline: {baseline} paper(s) already in the database")
|
|
|
|
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": "测试期刊",
|
|
},
|
|
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"] == baseline,
|
|
f"expected {baseline} paper(s) to remain",
|
|
)
|
|
|
|
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())
|