"""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())